mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Add agent context observability events (memory, skills, MCP tools) (#356)
## Summary
Adds three new durable run events — `agent.memory.loaded`,
`agent.skills.discovered`, and `agent.skill.activated` — and enriches
`agent.mcp.ready` with names-only tool summaries. Consumers can now
reconstruct what memory, skills, and MCP tools were active for any agent
run by reading the event stream, without needing to inspect session
state.
### Plan Summary
- **`fabro-types`**: New prop structs (`AgentMemoryLoadedProps`,
`AgentSkillsDiscoveredProps`, `AgentSkillActivatedProps`,
`AgentMcpToolSummary`) and three new `EventBody` variants with canonical
dot-name serialization. `AgentMcpReadyProps.tools` uses
`#[serde(default, skip_serializing_if = "Vec::is_empty")]` for backwards
compatibility.
- **`fabro-agent/memory.rs`**: `discover_memory` now returns
`Vec<MemoryDocument>` carrying path, byte counts, and truncation flag
alongside content. The content itself is never put in any event payload.
- **`fabro-agent/types.rs`**: Adds `MemoryLoaded`, `SkillsDiscovered`,
`SkillActivated`, and enriched `McpServerReady` internal variants.
Removes `SkillExpanded` (replaced by `SkillActivated { source: Slash
}`). New variants are **not** classified as streaming noise, so they
persist.
- **`fabro-agent/session.rs`**: Emits `MemoryLoaded` before skills init,
`SkillsDiscovered` after skill discovery, and enriches `McpServerReady`
with summaries from `McpConnectionManager::tool_summaries_for_server`.
Slash expansion now emits `SkillActivated { source: Slash }` instead of
`SkillExpanded`.
- **`fabro-agent/skills.rs`**: `make_use_skill_tool` emits
`SkillActivated { source: Tool }` on successful lookup only.
- **`fabro-mcp/connection_manager.rs`**: New `tool_summaries_for_server`
returns sorted `(qualified_name, original_name)` pairs without leaking
descriptions or schemas.
- **`fabro-workflow/event/convert.rs` + `names.rs`**: Converts all new
agent events to their typed `fabro-types` props, including `visit`
injection. Removes dead `SkillExpanded` arm.
- **`docs/internal/events.md`**: Documents all new event shapes with
full property tables; notes that `agent.skill.expanded` is replaced.
### Key design decisions
- Both `MemoryLoaded` and `SkillsDiscovered` are emitted even when the
result is empty. This lets consumers distinguish "no memory/skills
found" from "event not yet reported."
- Memory file **contents are never included** in any event payload —
only `path`, `byte_count`, `loaded_bytes`, and `truncated`.
- `agent.mcp.ready` `tools` field is omitted from JSON when empty
(`skip_serializing_if`), preserving wire compatibility with existing
stored events.
- `SkillActivated` is persisted (not filtered as streaming noise),
unlike the former internal-only `SkillExpanded`.
### Fabro Details
<details>
<summary>Ran 9 stages in 57m 15s for $27.15</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 17s | – | 0 |
| preflight_lint | 2m 30s | – | 0 |
| implement | 21m 18s | $16.27 | 0 |
| simplify_opus | 15m 21s | $6.52 | 0 |
| simplify_gpt | 10m 33s | $4.35 | 0 |
| verify | 4m 24s | – | 0 |
| fmt | 3s | – | 0 |
| **Total** | **57m 15s** | **$27.15** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-7; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3]
fmt [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> fmt [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
fmt -> exit
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
parent
0754f1ca4a
commit
a2e2cbc7ed
13 changed files with 1176 additions and 76 deletions
|
|
@ -1468,8 +1468,19 @@ Emitted when a sub-agent is spawned.
|
|||
"node_id": "code", "node_label": "code",
|
||||
"session_id": "ses_abc",
|
||||
"properties": {
|
||||
"server_name": "filesystem",
|
||||
"tool_count": 5
|
||||
"server_name": "github",
|
||||
"tool_count": 2,
|
||||
"tools": [
|
||||
{
|
||||
"name": "mcp__github__create_issue",
|
||||
"original_name": "create_issue"
|
||||
},
|
||||
{
|
||||
"name": "mcp__github__list_issues",
|
||||
"original_name": "list_issues"
|
||||
}
|
||||
],
|
||||
"visit": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -1478,6 +1489,8 @@ Emitted when a sub-agent is spawned.
|
|||
|----------|------|-------------|
|
||||
| `server_name` | string | MCP server name |
|
||||
| `tool_count` | number | Number of tools available |
|
||||
| `tools` | array | Names-only tool summaries for the ready server, sorted by qualified `name`. Each entry has `name` (Fabro-qualified `mcp__{server}__{tool}` identifier) and `original_name` (server-provided tool name). Descriptions and input schemas are intentionally omitted. The field is omitted from serialized JSON for legacy parity when empty. |
|
||||
| `visit` | number | Stage visit count when the server became ready |
|
||||
|
||||
### `agent.mcp.failed`
|
||||
|
||||
|
|
@ -1499,6 +1512,121 @@ Emitted when a sub-agent is spawned.
|
|||
| `server_name` | string | MCP server name |
|
||||
| `error` | string | Error message |
|
||||
|
||||
### `agent.memory.loaded`
|
||||
|
||||
Emitted once per session right after memory discovery, before skills and MCP
|
||||
initialization. The event is always emitted, even when no memory files are
|
||||
loaded (in which case `files` is an empty array). Memory file **contents are
|
||||
deliberately excluded** from the payload to keep the durable event stream free
|
||||
of project documentation bytes; consumers that need contents must read the
|
||||
files themselves.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...", "ts": "...", "run_id": "...",
|
||||
"event": "agent.memory.loaded",
|
||||
"node_id": "code", "node_label": "code",
|
||||
"session_id": "ses_abc",
|
||||
"properties": {
|
||||
"provider_profile": "anthropic",
|
||||
"files": [
|
||||
{
|
||||
"path": "/repo/AGENTS.md",
|
||||
"byte_count": 4096,
|
||||
"loaded_bytes": 4096,
|
||||
"truncated": false
|
||||
}
|
||||
],
|
||||
"total_loaded_bytes": 4096,
|
||||
"budget_bytes": 32768,
|
||||
"visit": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `provider_profile` | string | Active agent profile (`anthropic`, `openai`, `gemini`) |
|
||||
| `files` | array | Discovered memory files. Empty when no memory was loaded. |
|
||||
| `files[].path` | string | Absolute path of the memory file in the sandbox |
|
||||
| `files[].byte_count` | number | Original file size in bytes |
|
||||
| `files[].loaded_bytes` | number | Bytes actually loaded into the prompt budget |
|
||||
| `files[].truncated` | boolean | `true` if the file was truncated to fit the budget |
|
||||
| `total_loaded_bytes` | number | Sum of `files[].loaded_bytes` |
|
||||
| `budget_bytes` | number | Total memory budget for the session (currently 32 KiB) |
|
||||
| `visit` | number | Stage visit count |
|
||||
|
||||
### `agent.skills.discovered`
|
||||
|
||||
Emitted once per session right after skill discovery completes. The event is
|
||||
always emitted, even when no skills are found (`skills` is an empty array).
|
||||
Skills are sorted by name. `source_dirs` lists the directories that were
|
||||
scanned in the configured precedence order.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...", "ts": "...", "run_id": "...",
|
||||
"event": "agent.skills.discovered",
|
||||
"node_id": "code", "node_label": "code",
|
||||
"session_id": "ses_abc",
|
||||
"properties": {
|
||||
"provider_profile": "anthropic",
|
||||
"source_dirs": [
|
||||
"/home/test/.fabro/skills",
|
||||
"/repo/.fabro/skills",
|
||||
"/repo/skills"
|
||||
],
|
||||
"skills": [
|
||||
{ "name": "commit", "description": "Make a commit" }
|
||||
],
|
||||
"visit": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `provider_profile` | string | Active agent profile |
|
||||
| `source_dirs` | array | Directories scanned for `SKILL.md` files (in precedence order) |
|
||||
| `skills` | array | Discovered skills, sorted by `name`. Each entry is `{ name, description }`. |
|
||||
| `visit` | number | Stage visit count |
|
||||
|
||||
### `agent.skill.activated`
|
||||
|
||||
Emitted whenever a skill is activated in the running session. Sources:
|
||||
|
||||
- `slash` — the user input matched a `/skill-name` token and the skill template
|
||||
was expanded inline. This event replaces the previous internal-only
|
||||
`agent.skill.expanded` notification.
|
||||
- `tool` — the model successfully called the `use_skill` tool and the skill
|
||||
template was returned. Failed `use_skill` lookups (unknown names, missing
|
||||
parameters) do **not** emit this event.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...", "ts": "...", "run_id": "...",
|
||||
"event": "agent.skill.activated",
|
||||
"node_id": "code", "node_label": "code",
|
||||
"session_id": "ses_abc",
|
||||
"properties": {
|
||||
"skill_name": "commit",
|
||||
"source": "slash",
|
||||
"visit": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `skill_name` | string | Name of the activated skill |
|
||||
| `source` | string | `"slash"` for `/skill-name` expansion, `"tool"` for `use_skill` activations |
|
||||
| `visit` | number | Stage visit count |
|
||||
|
||||
> `agent.skill.expanded` is no longer surfaced as a durable run event. The
|
||||
> internal `AgentEvent::SkillExpanded` variant remains classified as streaming
|
||||
> noise and is not persisted; slash-skill expansion is reported through
|
||||
> `agent.skill.activated` with `source == "slash"` instead.
|
||||
|
||||
### `agent.failover`
|
||||
|
||||
Emitted when the agent fails over to a different LLM provider/model.
|
||||
|
|
@ -2307,4 +2435,4 @@ Emitted when the stall watchdog detects no progress.
|
|||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `idle_seconds` | number | Seconds since last activity |
|
||||
| `idle_seconds` | number | Seconds since last activity |
|
||||
|
|
@ -43,7 +43,7 @@ pub use fabro_types::SteeringMessage;
|
|||
pub use history::History;
|
||||
pub use local_sandbox::LocalSandbox;
|
||||
pub use loop_detection::detect_loop;
|
||||
pub use memory::discover_memory;
|
||||
pub use memory::{MemoryDocument, discover_memory};
|
||||
pub use profiles::{AnthropicProfile, EnvContext, GeminiProfile, OpenAiProfile};
|
||||
pub use read_before_write_sandbox::ReadBeforeWriteSandbox;
|
||||
pub use sandbox::{
|
||||
|
|
@ -70,7 +70,10 @@ pub use tools::{
|
|||
make_shell_tool, make_shell_tool_with_config, make_write_file_tool, register_core_tools,
|
||||
};
|
||||
pub use truncation::{TruncationMode, truncate_lines, truncate_output, truncate_tool_output};
|
||||
pub use types::{AgentEvent, Message, SessionEvent, SessionState};
|
||||
pub use types::{
|
||||
AgentEvent, McpToolSummary, MemoryFileSummary, Message, SessionEvent, SessionState,
|
||||
SkillActivationSource, SkillSummary,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,19 @@ use tracing::{debug, info, warn};
|
|||
use crate::error::{Error, InterruptReason};
|
||||
use crate::sandbox::Sandbox;
|
||||
|
||||
const BUDGET_BYTES: usize = 32768;
|
||||
pub const BUDGET_BYTES: usize = 32768;
|
||||
|
||||
/// One discovered memory file. `content` is what gets inlined into the
|
||||
/// system prompt. The remaining fields describe the file for
|
||||
/// observability and never carry the file's text.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MemoryDocument {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
pub byte_count: usize,
|
||||
pub loaded_bytes: usize,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
pub async fn discover_memory(
|
||||
env: &dyn Sandbox,
|
||||
|
|
@ -15,7 +27,7 @@ pub async fn discover_memory(
|
|||
working_dir: &str,
|
||||
profile_kind: AgentProfileKind,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> Result<Vec<String>, Error> {
|
||||
) -> Result<Vec<MemoryDocument>, Error> {
|
||||
let directories = build_directory_walk(git_root, working_dir);
|
||||
|
||||
let candidate_filenames: Vec<&str> = match profile_kind {
|
||||
|
|
@ -24,7 +36,7 @@ pub async fn discover_memory(
|
|||
AgentProfileKind::Gemini => vec!["AGENTS.md", "GEMINI.md"],
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut results: Vec<MemoryDocument> = Vec::new();
|
||||
let mut budget_remaining = BUDGET_BYTES;
|
||||
let mut seen_content = HashSet::new();
|
||||
|
||||
|
|
@ -47,28 +59,42 @@ pub async fn discover_memory(
|
|||
debug!(path = %path, "Project doc duplicate content, skipping");
|
||||
continue;
|
||||
}
|
||||
if content.len() <= budget_remaining {
|
||||
debug!(path = %path, size_bytes = content.len(), "Project doc loaded");
|
||||
budget_remaining -= content.len();
|
||||
results.push(content);
|
||||
let byte_count = content.len();
|
||||
if byte_count <= budget_remaining {
|
||||
debug!(path = %path, size_bytes = byte_count, "Project doc loaded");
|
||||
budget_remaining -= byte_count;
|
||||
results.push(MemoryDocument {
|
||||
path,
|
||||
content,
|
||||
byte_count,
|
||||
loaded_bytes: byte_count,
|
||||
truncated: false,
|
||||
});
|
||||
} else if budget_remaining > 0 {
|
||||
warn!(
|
||||
path = %path,
|
||||
size_bytes = content.len(),
|
||||
size_bytes = byte_count,
|
||||
budget_remaining,
|
||||
"Project doc truncated to fit budget"
|
||||
);
|
||||
let truncated = truncate_to_budget(&content, budget_remaining);
|
||||
let loaded_bytes = truncated.len();
|
||||
budget_remaining = 0;
|
||||
results.push(truncated);
|
||||
results.push(MemoryDocument {
|
||||
path,
|
||||
content: truncated,
|
||||
byte_count,
|
||||
loaded_bytes,
|
||||
truncated: true,
|
||||
});
|
||||
} else {
|
||||
warn!(path = %path, size_bytes = content.len(), "Project doc skipped, budget exhausted");
|
||||
warn!(path = %path, size_bytes = byte_count, "Project doc skipped, budget exhausted");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total_bytes: usize = results.iter().map(std::string::String::len).sum();
|
||||
let total_bytes: usize = results.iter().map(|doc| doc.loaded_bytes).sum();
|
||||
info!(files = results.len(), total_bytes, "Project docs loaded");
|
||||
|
||||
Ok(results)
|
||||
|
|
@ -144,7 +170,11 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
assert_eq!(docs.len(), 1);
|
||||
assert_eq!(docs[0], "Agent instructions");
|
||||
assert_eq!(docs[0].content, "Agent instructions");
|
||||
assert_eq!(docs[0].path, "/repo/AGENTS.md");
|
||||
assert_eq!(docs[0].byte_count, "Agent instructions".len());
|
||||
assert_eq!(docs[0].loaded_bytes, docs[0].byte_count);
|
||||
assert!(!docs[0].truncated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -169,8 +199,8 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
assert_eq!(anthropic_docs.len(), 2);
|
||||
assert_eq!(anthropic_docs[0], "agents");
|
||||
assert_eq!(anthropic_docs[1], "claude");
|
||||
assert_eq!(anthropic_docs[0].content, "agents");
|
||||
assert_eq!(anthropic_docs[1].content, "claude");
|
||||
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
|
||||
files: files.clone(),
|
||||
|
|
@ -186,8 +216,8 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
assert_eq!(openai_docs.len(), 2);
|
||||
assert_eq!(openai_docs[0], "agents");
|
||||
assert_eq!(openai_docs[1], "copilot");
|
||||
assert_eq!(openai_docs[0].content, "agents");
|
||||
assert_eq!(openai_docs[1].content, "copilot");
|
||||
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
|
||||
files,
|
||||
|
|
@ -203,8 +233,8 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
assert_eq!(gemini_docs.len(), 2);
|
||||
assert_eq!(gemini_docs[0], "agents");
|
||||
assert_eq!(gemini_docs[1], "gemini");
|
||||
assert_eq!(gemini_docs[0].content, "agents");
|
||||
assert_eq!(gemini_docs[1].content, "gemini");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -230,10 +260,18 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
assert_eq!(docs.len(), 2);
|
||||
assert_eq!(docs[0], large_content);
|
||||
assert_eq!(docs[0].content, large_content);
|
||||
assert!(!docs[0].truncated);
|
||||
assert_eq!(docs[0].byte_count, docs[0].content.len());
|
||||
// Second doc should be truncated to fit remaining budget
|
||||
assert!(docs[1].ends_with("[Project instructions truncated at 32KB]"));
|
||||
assert!(docs[0].len() + docs[1].len() <= BUDGET_BYTES);
|
||||
assert!(
|
||||
docs[1]
|
||||
.content
|
||||
.ends_with("[Project instructions truncated at 32KB]")
|
||||
);
|
||||
assert!(docs[1].truncated);
|
||||
assert!(docs[1].byte_count > docs[1].content.len());
|
||||
assert!(docs[0].content.len() + docs[1].content.len() <= BUDGET_BYTES);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -255,7 +293,7 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
assert_eq!(docs.len(), 1);
|
||||
assert_eq!(docs[0], "shared instructions");
|
||||
assert_eq!(docs[0].content, "shared instructions");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -277,7 +315,35 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
assert_eq!(docs.len(), 1);
|
||||
assert_eq!(docs[0], "shared instructions");
|
||||
assert_eq!(docs[0].content, "shared instructions");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn truncated_file_reports_byte_count_distinct_from_loaded_bytes() {
|
||||
let mut files = HashMap::new();
|
||||
// Single file larger than the budget so we hit the truncation branch
|
||||
// without any preceding consumption.
|
||||
let large_content = "x".repeat(BUDGET_BYTES + 1024);
|
||||
files.insert("/repo/AGENTS.md".into(), large_content.clone());
|
||||
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
|
||||
files,
|
||||
..Default::default()
|
||||
});
|
||||
let docs = discover_memory(
|
||||
env.as_ref(),
|
||||
"/repo",
|
||||
"/repo",
|
||||
AgentProfileKind::Anthropic,
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(docs.len(), 1);
|
||||
assert!(docs[0].truncated);
|
||||
assert_eq!(docs[0].byte_count, large_content.len());
|
||||
assert!(docs[0].content.len() < docs[0].byte_count);
|
||||
assert!(docs[0].content.len() <= BUDGET_BYTES);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -301,8 +367,8 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
assert_eq!(docs.len(), 3);
|
||||
assert_eq!(docs[0], "root agents");
|
||||
assert_eq!(docs[1], "src agents");
|
||||
assert_eq!(docs[2], "app agents");
|
||||
assert_eq!(docs[0].content, "root agents");
|
||||
assert_eq!(docs[1].content, "src agents");
|
||||
assert_eq!(docs[2].content, "app agents");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ use crate::file_tracker::FileTracker;
|
|||
use crate::history::History;
|
||||
use crate::loop_detection::detect_loop;
|
||||
use crate::mcp_integration;
|
||||
use crate::memory::discover_memory;
|
||||
use crate::memory::{BUDGET_BYTES, MemoryDocument, discover_memory};
|
||||
use crate::profiles::EnvContext;
|
||||
use crate::sandbox::Sandbox;
|
||||
use crate::skills::{
|
||||
|
|
@ -39,7 +39,10 @@ use crate::skills::{
|
|||
};
|
||||
use crate::subagent::{SubAgentCallbackEvent, SubAgentEventCallback, SubAgentManager};
|
||||
use crate::tool_execution::execute_tool_calls;
|
||||
use crate::types::{AgentEvent, Message, SessionEvent, SessionState};
|
||||
use crate::types::{
|
||||
AgentEvent, McpToolSummary, MemoryFileSummary, Message, SessionEvent, SessionState,
|
||||
SkillActivationSource, SkillSummary,
|
||||
};
|
||||
|
||||
/// One queued external control item for a live session.
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -311,7 +314,7 @@ pub struct Session {
|
|||
cancel_token: CancellationToken,
|
||||
round_token: Arc<RwLock<CancellationToken>>,
|
||||
interrupt_reason: Arc<Mutex<Option<InterruptReason>>>,
|
||||
memory: Vec<String>,
|
||||
memory: Vec<MemoryDocument>,
|
||||
env_context: EnvContext,
|
||||
skills: Vec<Skill>,
|
||||
system_prompt: String,
|
||||
|
|
@ -489,6 +492,29 @@ impl Session {
|
|||
)
|
||||
.await?;
|
||||
|
||||
let provider_profile = self.provider_profile.profile_kind().to_string();
|
||||
|
||||
// Emit memory loaded event with file metadata. Contents are deliberately
|
||||
// omitted so the durable event stream never carries file bytes.
|
||||
let memory_files: Vec<MemoryFileSummary> = self
|
||||
.memory
|
||||
.iter()
|
||||
.map(|doc| MemoryFileSummary {
|
||||
path: doc.path.clone(),
|
||||
byte_count: doc.byte_count,
|
||||
loaded_bytes: doc.loaded_bytes,
|
||||
truncated: doc.truncated,
|
||||
})
|
||||
.collect();
|
||||
let total_loaded_bytes = self.memory.iter().map(|doc| doc.loaded_bytes).sum();
|
||||
self.event_emitter
|
||||
.emit(self.id.clone(), AgentEvent::MemoryLoaded {
|
||||
provider_profile: provider_profile.clone(),
|
||||
files: memory_files,
|
||||
total_loaded_bytes,
|
||||
budget_bytes: BUDGET_BYTES,
|
||||
});
|
||||
|
||||
// Discover skills
|
||||
let skill_dirs = if let Some(dirs) = &self.config.skill_dirs {
|
||||
dirs.clone()
|
||||
|
|
@ -500,6 +526,21 @@ impl Session {
|
|||
self.skills = discover_skills(self.sandbox.as_ref(), &skill_dirs, &cancel_token).await?;
|
||||
debug!(skill_count = self.skills.len(), "Skills discovered");
|
||||
|
||||
let skill_summaries: Vec<SkillSummary> = self
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| SkillSummary {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
})
|
||||
.collect();
|
||||
self.event_emitter
|
||||
.emit(self.id.clone(), AgentEvent::SkillsDiscovered {
|
||||
provider_profile,
|
||||
source_dirs: skill_dirs.clone(),
|
||||
skills: skill_summaries,
|
||||
});
|
||||
|
||||
// Register use_skill tool when skills are available
|
||||
if !self.skills.is_empty() {
|
||||
let skills_arc = Arc::new(self.skills.clone());
|
||||
|
|
@ -522,10 +563,19 @@ impl Session {
|
|||
for (server_name, result) in &results {
|
||||
match result {
|
||||
Ok(tool_count) => {
|
||||
let tools = manager
|
||||
.tool_summaries_for_server(server_name)
|
||||
.into_iter()
|
||||
.map(|(name, original_name)| McpToolSummary {
|
||||
name,
|
||||
original_name,
|
||||
})
|
||||
.collect();
|
||||
self.event_emitter
|
||||
.emit(self.id.clone(), AgentEvent::McpServerReady {
|
||||
server_name: server_name.clone(),
|
||||
tool_count: *tool_count,
|
||||
tool_count: *tool_count,
|
||||
tools,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -555,11 +605,15 @@ impl Session {
|
|||
"Environment context built"
|
||||
);
|
||||
|
||||
// Build system prompt once (static for the session lifetime)
|
||||
// Build system prompt once (static for the session lifetime). Only
|
||||
// the loaded memory text is passed to the profile; the document
|
||||
// metadata is already surfaced via the `agent.memory.loaded` event.
|
||||
let memory_contents: Vec<String> =
|
||||
self.memory.iter().map(|doc| doc.content.clone()).collect();
|
||||
self.system_prompt = self.provider_profile.build_system_prompt(
|
||||
self.sandbox.as_ref(),
|
||||
&self.env_context,
|
||||
&self.memory,
|
||||
&memory_contents,
|
||||
self.config.user_instructions.as_deref(),
|
||||
&self.skills,
|
||||
);
|
||||
|
|
@ -1138,8 +1192,9 @@ impl Session {
|
|||
};
|
||||
if let Some(ref name) = expanded.skill_name {
|
||||
self.event_emitter
|
||||
.emit(self.id.clone(), AgentEvent::SkillExpanded {
|
||||
.emit(self.id.clone(), AgentEvent::SkillActivated {
|
||||
skill_name: name.clone(),
|
||||
source: SkillActivationSource::Slash,
|
||||
});
|
||||
}
|
||||
let expanded_input = expanded.text;
|
||||
|
|
@ -1710,9 +1765,10 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
use crate::config::{ToolAccess, ToolAccessPolicy, ToolApprovalAdapter, ToolExposureMode};
|
||||
use crate::skills::{Skill, make_use_skill_tool};
|
||||
use crate::subagent::SubAgentStatus;
|
||||
use crate::test_support::*;
|
||||
use crate::tool_registry::{RegisteredTool, ToolRegistry};
|
||||
use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry};
|
||||
|
||||
struct NamedToolAccessPolicy {
|
||||
decisions: Vec<(&'static str, ToolAccess)>,
|
||||
|
|
@ -3649,16 +3705,18 @@ mod tests {
|
|||
// Initialize starts the MCP server and registers tools
|
||||
session.initialize().await.unwrap();
|
||||
|
||||
// Verify McpServerReady event was emitted
|
||||
// Verify McpServerReady event was emitted with deterministic tool
|
||||
// summaries pulled from the connection manager.
|
||||
let mut mcp_ready = false;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AgentEvent::McpServerReady {
|
||||
server_name,
|
||||
tool_count,
|
||||
server_name, tools, ..
|
||||
} = &event.event
|
||||
{
|
||||
assert_eq!(server_name, "test-echo");
|
||||
assert_eq!(*tool_count, 1);
|
||||
assert_eq!(tools.len(), 1);
|
||||
assert_eq!(tools[0].name, "mcp__test_echo__echo");
|
||||
assert_eq!(tools[0].original_name, "echo");
|
||||
mcp_ready = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -3864,4 +3922,277 @@ mod tests {
|
|||
"ProcessingEnd event should be emitted when returning to Idle"
|
||||
);
|
||||
}
|
||||
|
||||
async fn build_initialized_session(
|
||||
sandbox: Arc<MockSandbox>,
|
||||
config: SessionOptions,
|
||||
) -> Session {
|
||||
let provider = Arc::new(MockLlmProvider::new(vec![text_response("ok")]));
|
||||
let client = make_client(provider).await;
|
||||
let profile = Arc::new(TestProfile::new());
|
||||
Session::new(client, profile, sandbox, config, None)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_emits_memory_loaded_with_file_metadata() {
|
||||
let mut files = std::collections::HashMap::new();
|
||||
files.insert("/home/test/AGENTS.md".into(), "Hello world".into());
|
||||
let sandbox = Arc::new(MockSandbox {
|
||||
files,
|
||||
..MockSandbox::linux()
|
||||
});
|
||||
let config = SessionOptions {
|
||||
git_root: Some("/home/test".into()),
|
||||
skill_dirs: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = build_initialized_session(sandbox, config).await;
|
||||
let mut rx = session.subscribe();
|
||||
session.initialize().await.unwrap();
|
||||
|
||||
let mut memory_event = None;
|
||||
while let Ok(envelope) = rx.try_recv() {
|
||||
if let AgentEvent::MemoryLoaded {
|
||||
files,
|
||||
budget_bytes,
|
||||
provider_profile,
|
||||
..
|
||||
} = envelope.event
|
||||
{
|
||||
memory_event = Some((files, budget_bytes, provider_profile));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (files, budget_bytes, provider_profile) =
|
||||
memory_event.expect("MemoryLoaded should be emitted");
|
||||
assert_eq!(provider_profile, "anthropic");
|
||||
assert_eq!(budget_bytes, 32768);
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(files[0].path, "/home/test/AGENTS.md");
|
||||
assert_eq!(files[0].byte_count, "Hello world".len());
|
||||
assert_eq!(files[0].loaded_bytes, "Hello world".len());
|
||||
assert!(!files[0].truncated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_emits_memory_loaded_event_with_empty_files_when_no_memory() {
|
||||
let sandbox = Arc::new(MockSandbox::linux());
|
||||
let config = SessionOptions {
|
||||
git_root: Some("/home/test".into()),
|
||||
skill_dirs: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = build_initialized_session(sandbox, config).await;
|
||||
let mut rx = session.subscribe();
|
||||
session.initialize().await.unwrap();
|
||||
|
||||
let mut saw_memory = false;
|
||||
while let Ok(envelope) = rx.try_recv() {
|
||||
if let AgentEvent::MemoryLoaded { files, .. } = envelope.event {
|
||||
assert!(files.is_empty());
|
||||
saw_memory = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_memory,
|
||||
"MemoryLoaded must be emitted even when no memory files are loaded"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_emits_skills_discovered_with_summaries() {
|
||||
let mut files = std::collections::HashMap::new();
|
||||
files.insert(
|
||||
"/skills/commit/SKILL.md".into(),
|
||||
"---\nname: commit\ndescription: Make a commit\n---\nDo commit".into(),
|
||||
);
|
||||
let sandbox = Arc::new(MockSandbox {
|
||||
files,
|
||||
glob_results: vec!["/skills/commit/SKILL.md".into()],
|
||||
..MockSandbox::linux()
|
||||
});
|
||||
let config = SessionOptions {
|
||||
git_root: Some("/home/test".into()),
|
||||
skill_dirs: Some(vec!["/skills".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = build_initialized_session(sandbox, config).await;
|
||||
let mut rx = session.subscribe();
|
||||
session.initialize().await.unwrap();
|
||||
|
||||
let mut got = None;
|
||||
while let Ok(envelope) = rx.try_recv() {
|
||||
if let AgentEvent::SkillsDiscovered {
|
||||
provider_profile,
|
||||
source_dirs,
|
||||
skills,
|
||||
} = envelope.event
|
||||
{
|
||||
got = Some((provider_profile, source_dirs, skills));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (provider_profile, source_dirs, skills) =
|
||||
got.expect("SkillsDiscovered must be emitted");
|
||||
assert_eq!(provider_profile, "anthropic");
|
||||
assert_eq!(source_dirs, vec!["/skills".to_string()]);
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "commit");
|
||||
assert_eq!(skills[0].description, "Make a commit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_emits_skills_discovered_event_when_no_skills() {
|
||||
let sandbox = Arc::new(MockSandbox::linux());
|
||||
let config = SessionOptions {
|
||||
git_root: Some("/home/test".into()),
|
||||
skill_dirs: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = build_initialized_session(sandbox, config).await;
|
||||
let mut rx = session.subscribe();
|
||||
session.initialize().await.unwrap();
|
||||
|
||||
let mut saw_skills = false;
|
||||
while let Ok(envelope) = rx.try_recv() {
|
||||
if let AgentEvent::SkillsDiscovered { skills, .. } = envelope.event {
|
||||
assert!(skills.is_empty());
|
||||
saw_skills = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_skills,
|
||||
"SkillsDiscovered must be emitted even when no skills are present"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_skill_expansion_emits_skill_activated_with_slash_source() {
|
||||
let mut files = std::collections::HashMap::new();
|
||||
files.insert(
|
||||
"/skills/commit/SKILL.md".into(),
|
||||
"---\nname: commit\ndescription: Make a commit\n---\nRun commit. {{user_input}}".into(),
|
||||
);
|
||||
let sandbox = Arc::new(MockSandbox {
|
||||
files,
|
||||
glob_results: vec!["/skills/commit/SKILL.md".into()],
|
||||
..MockSandbox::linux()
|
||||
});
|
||||
let config = SessionOptions {
|
||||
git_root: Some("/home/test".into()),
|
||||
skill_dirs: Some(vec!["/skills".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let provider = Arc::new(MockLlmProvider::new(vec![text_response("ok")]));
|
||||
let client = make_client(provider).await;
|
||||
let profile = Arc::new(TestProfile::new());
|
||||
let mut session = Session::new(client, profile, sandbox, config, None);
|
||||
session.initialize().await.unwrap();
|
||||
|
||||
let mut rx = session.subscribe();
|
||||
session.process_input("/commit fix things").await.unwrap();
|
||||
|
||||
let mut activations: Vec<(String, SkillActivationSource)> = Vec::new();
|
||||
while let Ok(envelope) = rx.try_recv() {
|
||||
if let AgentEvent::SkillActivated { skill_name, source } = envelope.event {
|
||||
activations.push((skill_name, source));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
activations
|
||||
.iter()
|
||||
.any(|(name, source)| name == "commit" && *source == SkillActivationSource::Slash),
|
||||
"expected slash skill activation, got {activations:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn use_skill_tool_success_emits_skill_activated_with_tool_source() {
|
||||
let mut files = std::collections::HashMap::new();
|
||||
files.insert(
|
||||
"/skills/commit/SKILL.md".into(),
|
||||
"---\nname: commit\ndescription: Make a commit\n---\nRun commit.".into(),
|
||||
);
|
||||
let sandbox = Arc::new(MockSandbox {
|
||||
files,
|
||||
glob_results: vec!["/skills/commit/SKILL.md".into()],
|
||||
..MockSandbox::linux()
|
||||
});
|
||||
let config = SessionOptions {
|
||||
git_root: Some("/home/test".into()),
|
||||
skill_dirs: Some(vec!["/skills".into()]),
|
||||
enable_loop_detection: false,
|
||||
..Default::default()
|
||||
};
|
||||
let responses = vec![
|
||||
tool_call_response(
|
||||
"use_skill",
|
||||
"call_1",
|
||||
serde_json::json!({"skill_name": "commit"}),
|
||||
),
|
||||
text_response("done"),
|
||||
];
|
||||
let provider = Arc::new(MockLlmProvider::new(responses));
|
||||
let client = make_client(provider).await;
|
||||
let profile = Arc::new(TestProfile::new());
|
||||
let mut session = Session::new(client, profile, sandbox, config, None);
|
||||
session.initialize().await.unwrap();
|
||||
|
||||
let mut rx = session.subscribe();
|
||||
session.process_input("please commit").await.unwrap();
|
||||
|
||||
let mut tool_activations = 0;
|
||||
while let Ok(envelope) = rx.try_recv() {
|
||||
if let AgentEvent::SkillActivated { source, skill_name } = envelope.event {
|
||||
if source == SkillActivationSource::Tool && skill_name == "commit" {
|
||||
tool_activations += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
tool_activations, 1,
|
||||
"expected exactly one tool-sourced skill activation"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn use_skill_tool_failed_lookup_does_not_emit_activation() {
|
||||
let sandbox = Arc::new(MockSandbox::linux());
|
||||
let config = SessionOptions {
|
||||
git_root: Some("/home/test".into()),
|
||||
skill_dirs: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let provider = Arc::new(MockLlmProvider::new(vec![text_response("ok")]));
|
||||
let client = make_client(provider).await;
|
||||
let profile = Arc::new(TestProfile::new());
|
||||
let mut session = Session::new(client, profile, sandbox, config, None);
|
||||
session.initialize().await.unwrap();
|
||||
|
||||
// Build a use_skill tool with an empty skill list, then invoke it
|
||||
// directly with a missing name. We must NOT see a SkillActivated event.
|
||||
let skills_arc = Arc::new(Vec::<Skill>::new());
|
||||
let tool = make_use_skill_tool(skills_arc);
|
||||
let mut rx = session.subscribe();
|
||||
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
|
||||
let ctx = ToolContext {
|
||||
env,
|
||||
cancel: CancellationToken::new(),
|
||||
tool_env_provider: None,
|
||||
session_id: Some(session.id().to_string()),
|
||||
root_session_id: Some(session.id().to_string()),
|
||||
tool_call_id: None,
|
||||
agent_event_emitter: None,
|
||||
};
|
||||
let result = (tool.executor)(serde_json::json!({"skill_name": "nope"}), ctx).await;
|
||||
assert!(result.is_err());
|
||||
|
||||
while let Ok(envelope) = rx.try_recv() {
|
||||
if matches!(envelope.event, AgentEvent::SkillActivated { .. }) {
|
||||
panic!("failed use_skill should not emit SkillActivated");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use crate::error::{Error, InterruptReason};
|
|||
use crate::sandbox::Sandbox;
|
||||
use crate::tool_registry::RegisteredTool;
|
||||
use crate::tools::required_str;
|
||||
use crate::types::{AgentEvent, SkillActivationSource};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Skill {
|
||||
|
|
@ -176,7 +177,7 @@ pub fn make_use_skill_tool(skills: Arc<Vec<Skill>>) -> RegisteredTool {
|
|||
"required": ["skill_name"]
|
||||
}),
|
||||
},
|
||||
executor: Arc::new(move |args, _ctx| {
|
||||
executor: Arc::new(move |args, ctx| {
|
||||
let skills = skills.clone();
|
||||
Box::pin(async move {
|
||||
let name = required_str(&args, "skill_name")?;
|
||||
|
|
@ -184,6 +185,10 @@ pub fn make_use_skill_tool(skills: Arc<Vec<Skill>>) -> RegisteredTool {
|
|||
.iter()
|
||||
.find(|s| s.name == name)
|
||||
.ok_or_else(|| format!("Unknown skill: {name}"))?;
|
||||
ctx.emit_agent_event(AgentEvent::SkillActivated {
|
||||
skill_name: name.to_string(),
|
||||
source: SkillActivationSource::Tool,
|
||||
});
|
||||
Ok(skill.template.clone())
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -194,6 +194,33 @@ pub enum SessionState {
|
|||
Closed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MemoryFileSummary {
|
||||
pub path: String,
|
||||
pub byte_count: usize,
|
||||
pub loaded_bytes: usize,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SkillSummary {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SkillActivationSource {
|
||||
Slash,
|
||||
Tool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct McpToolSummary {
|
||||
pub name: String,
|
||||
pub original_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum AgentEvent {
|
||||
SessionStarted {
|
||||
|
|
@ -252,9 +279,6 @@ pub enum AgentEvent {
|
|||
TurnLimitReached {
|
||||
max_turns: usize,
|
||||
},
|
||||
SkillExpanded {
|
||||
skill_name: String,
|
||||
},
|
||||
SteeringInjected {
|
||||
text: String,
|
||||
/// Principal that authored the steer. Lifted to top-level
|
||||
|
|
@ -303,11 +327,27 @@ pub enum AgentEvent {
|
|||
McpServerReady {
|
||||
server_name: String,
|
||||
tool_count: usize,
|
||||
tools: Vec<McpToolSummary>,
|
||||
},
|
||||
McpServerFailed {
|
||||
server_name: String,
|
||||
error: String,
|
||||
},
|
||||
MemoryLoaded {
|
||||
provider_profile: String,
|
||||
files: Vec<MemoryFileSummary>,
|
||||
total_loaded_bytes: usize,
|
||||
budget_bytes: usize,
|
||||
},
|
||||
SkillsDiscovered {
|
||||
provider_profile: String,
|
||||
source_dirs: Vec<String>,
|
||||
skills: Vec<SkillSummary>,
|
||||
},
|
||||
SkillActivated {
|
||||
skill_name: String,
|
||||
source: SkillActivationSource,
|
||||
},
|
||||
/// New todo / task was created. Carries the full row so the projection
|
||||
/// can be reconstructed from `todo.created` alone.
|
||||
TodoCreated(fabro_types::TodoCreatedProps),
|
||||
|
|
@ -330,7 +370,6 @@ impl AgentEvent {
|
|||
| Self::TextDelta { .. }
|
||||
| Self::ReasoningDelta { .. }
|
||||
| Self::ToolCallOutputDelta { .. }
|
||||
| Self::SkillExpanded { .. }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -420,9 +459,6 @@ impl AgentEvent {
|
|||
Self::TurnLimitReached { max_turns } => {
|
||||
warn!(session_id, max_turns, "Message limit reached");
|
||||
}
|
||||
Self::SkillExpanded { skill_name } => {
|
||||
debug!(session_id, skill = skill_name.as_str(), "Skill expanded");
|
||||
}
|
||||
Self::SteeringInjected { text, .. } => {
|
||||
debug!(session_id, text_len = text.len(), "Steering injected");
|
||||
}
|
||||
|
|
@ -504,14 +540,52 @@ impl AgentEvent {
|
|||
Self::McpServerReady {
|
||||
server_name,
|
||||
tool_count,
|
||||
tools,
|
||||
} => {
|
||||
info!(
|
||||
session_id,
|
||||
server = server_name.as_str(),
|
||||
tool_count,
|
||||
summary_count = tools.len(),
|
||||
"MCP server ready"
|
||||
);
|
||||
}
|
||||
Self::MemoryLoaded {
|
||||
provider_profile,
|
||||
files,
|
||||
total_loaded_bytes,
|
||||
budget_bytes,
|
||||
} => {
|
||||
info!(
|
||||
session_id,
|
||||
provider_profile = provider_profile.as_str(),
|
||||
file_count = files.len(),
|
||||
total_loaded_bytes,
|
||||
budget_bytes,
|
||||
"Agent memory loaded"
|
||||
);
|
||||
}
|
||||
Self::SkillsDiscovered {
|
||||
provider_profile,
|
||||
source_dirs,
|
||||
skills,
|
||||
} => {
|
||||
info!(
|
||||
session_id,
|
||||
provider_profile = %provider_profile,
|
||||
skill_count = skills.len(),
|
||||
source_dir_count = source_dirs.len(),
|
||||
"Agent skills discovered"
|
||||
);
|
||||
}
|
||||
Self::SkillActivated { skill_name, source } => {
|
||||
debug!(
|
||||
session_id,
|
||||
skill = skill_name.as_str(),
|
||||
source = ?source,
|
||||
"Agent skill activated"
|
||||
);
|
||||
}
|
||||
Self::McpServerFailed { server_name, error } => {
|
||||
error!(
|
||||
session_id,
|
||||
|
|
@ -609,16 +683,6 @@ mod tests {
|
|||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_expanded_constructible() {
|
||||
let event = AgentEvent::SkillExpanded {
|
||||
skill_name: "commit".into(),
|
||||
};
|
||||
assert!(
|
||||
matches!(event, AgentEvent::SkillExpanded { skill_name } if skill_name == "commit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_spawned_constructible() {
|
||||
let event = AgentEvent::SubAgentSpawned {
|
||||
|
|
@ -751,12 +815,13 @@ mod tests {
|
|||
fn mcp_server_ready_constructible() {
|
||||
let event = AgentEvent::McpServerReady {
|
||||
server_name: "filesystem".into(),
|
||||
tool_count: 3,
|
||||
tool_count: 0,
|
||||
tools: Vec::new(),
|
||||
};
|
||||
assert!(matches!(event, AgentEvent::McpServerReady {
|
||||
tool_count: 3,
|
||||
..
|
||||
}));
|
||||
assert!(matches!(
|
||||
event,
|
||||
AgentEvent::McpServerReady { server_name, .. } if server_name == "filesystem"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -775,7 +840,8 @@ mod tests {
|
|||
let events = vec![
|
||||
AgentEvent::McpServerReady {
|
||||
server_name: "fs".into(),
|
||||
tool_count: 5,
|
||||
tool_count: 0,
|
||||
tools: Vec::new(),
|
||||
},
|
||||
AgentEvent::McpServerFailed {
|
||||
server_name: "bad".into(),
|
||||
|
|
@ -785,10 +851,10 @@ mod tests {
|
|||
let json = serde_json::to_string(&events).unwrap();
|
||||
let deserialized: Vec<AgentEvent> = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.len(), 2);
|
||||
assert!(matches!(&deserialized[0], AgentEvent::McpServerReady {
|
||||
tool_count: 5,
|
||||
..
|
||||
}));
|
||||
assert!(matches!(
|
||||
&deserialized[0],
|
||||
AgentEvent::McpServerReady { server_name, .. } if server_name == "fs"
|
||||
));
|
||||
assert!(matches!(
|
||||
&deserialized[1],
|
||||
AgentEvent::McpServerFailed { .. }
|
||||
|
|
|
|||
|
|
@ -151,6 +151,22 @@ impl McpConnectionManager {
|
|||
&self.tools
|
||||
}
|
||||
|
||||
/// Names-only tool summaries for the given server, sorted by qualified
|
||||
/// name. Returns `(qualified_name, original_tool_name)` pairs. Useful
|
||||
/// for emitting deterministic `agent.mcp.ready` payloads without
|
||||
/// leaking descriptions or input schemas.
|
||||
#[must_use]
|
||||
pub fn tool_summaries_for_server(&self, server_name: &str) -> Vec<(String, String)> {
|
||||
let mut summaries: Vec<(String, String)> = self
|
||||
.tools
|
||||
.iter()
|
||||
.filter(|(_, info)| info.server_name == server_name)
|
||||
.map(|(qualified, info)| (qualified.clone(), info.original_tool_name.clone()))
|
||||
.collect();
|
||||
summaries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
summaries
|
||||
}
|
||||
|
||||
/// Call a tool by its qualified name.
|
||||
pub async fn call_tool(
|
||||
&self,
|
||||
|
|
@ -303,4 +319,45 @@ mod tests {
|
|||
let mgr = McpConnectionManager::new();
|
||||
assert!(mgr.all_tools().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_summaries_for_server_filters_and_sorts_by_qualified_name() {
|
||||
let mut mgr = McpConnectionManager::new();
|
||||
mgr.tools
|
||||
.insert(qualified_tool_name("github", "list_issues"), ToolInfo {
|
||||
server_name: "github".to_string(),
|
||||
original_tool_name: "list_issues".to_string(),
|
||||
description: "list issues".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
});
|
||||
mgr.tools
|
||||
.insert(qualified_tool_name("github", "create_issue"), ToolInfo {
|
||||
server_name: "github".to_string(),
|
||||
original_tool_name: "create_issue".to_string(),
|
||||
description: "create issue".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
});
|
||||
mgr.tools
|
||||
.insert(qualified_tool_name("other", "noop"), ToolInfo {
|
||||
server_name: "other".to_string(),
|
||||
original_tool_name: "noop".to_string(),
|
||||
description: "noop".to_string(),
|
||||
input_schema: serde_json::json!({}),
|
||||
});
|
||||
|
||||
let summaries = mgr.tool_summaries_for_server("github");
|
||||
assert_eq!(summaries.len(), 2);
|
||||
assert_eq!(summaries[0].0, "mcp__github__create_issue");
|
||||
assert_eq!(summaries[0].1, "create_issue");
|
||||
assert_eq!(summaries[1].0, "mcp__github__list_issues");
|
||||
assert_eq!(summaries[1].1, "list_issues");
|
||||
|
||||
let other = mgr.tool_summaries_for_server("other");
|
||||
assert_eq!(other.len(), 1);
|
||||
assert_eq!(other[0].0, "mcp__other__noop");
|
||||
assert_eq!(other[0].1, "noop");
|
||||
|
||||
let none = mgr.tool_summaries_for_server("missing");
|
||||
assert!(none.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ pub use run::{
|
|||
};
|
||||
pub use run_blob_id::RunBlobId;
|
||||
pub use run_event::{
|
||||
AgentMcpToolSummary, AgentMemoryFileProps, AgentSkillActivationSource, AgentSkillSummary,
|
||||
EventBody, ExecOutputTail, InterviewOption, MetadataSnapshotFailureKind, MetadataSnapshotPhase,
|
||||
RunEvent, RunNoticeCode, RunNoticeLevel, RunPairEndedReason, RunPairFailedReason,
|
||||
SessionCapability, TodoCreatedProps, TodoDeletedProps, TodoUpdatedProps,
|
||||
|
|
|
|||
|
|
@ -215,12 +215,65 @@ pub struct AgentSubClosedProps {
|
|||
pub struct AgentMcpReadyProps {
|
||||
pub server_name: String,
|
||||
pub tool_count: usize,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<AgentMcpToolSummary>,
|
||||
pub visit: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AgentMcpToolSummary {
|
||||
pub name: String,
|
||||
pub original_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AgentMcpFailedProps {
|
||||
pub server_name: String,
|
||||
pub error: String,
|
||||
pub visit: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AgentMemoryLoadedProps {
|
||||
pub provider_profile: String,
|
||||
pub files: Vec<AgentMemoryFileProps>,
|
||||
pub total_loaded_bytes: usize,
|
||||
pub budget_bytes: usize,
|
||||
pub visit: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AgentMemoryFileProps {
|
||||
pub path: String,
|
||||
pub byte_count: usize,
|
||||
pub loaded_bytes: usize,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AgentSkillsDiscoveredProps {
|
||||
pub provider_profile: String,
|
||||
pub source_dirs: Vec<String>,
|
||||
pub skills: Vec<AgentSkillSummary>,
|
||||
pub visit: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AgentSkillSummary {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentSkillActivationSource {
|
||||
Slash,
|
||||
Tool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AgentSkillActivatedProps {
|
||||
pub skill_name: String,
|
||||
pub source: AgentSkillActivationSource,
|
||||
pub visit: u32,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,6 +242,12 @@ pub enum EventBody {
|
|||
AgentMcpReady(AgentMcpReadyProps),
|
||||
#[serde(rename = "agent.mcp.failed")]
|
||||
AgentMcpFailed(AgentMcpFailedProps),
|
||||
#[serde(rename = "agent.memory.loaded")]
|
||||
AgentMemoryLoaded(AgentMemoryLoadedProps),
|
||||
#[serde(rename = "agent.skills.discovered")]
|
||||
AgentSkillsDiscovered(AgentSkillsDiscoveredProps),
|
||||
#[serde(rename = "agent.skill.activated")]
|
||||
AgentSkillActivated(AgentSkillActivatedProps),
|
||||
#[serde(rename = "todo.created")]
|
||||
TodoCreated(TodoCreatedProps),
|
||||
#[serde(rename = "todo.updated")]
|
||||
|
|
@ -506,6 +512,9 @@ impl EventBody {
|
|||
Self::AgentSubClosed(_) => "agent.sub.closed",
|
||||
Self::AgentMcpReady(_) => "agent.mcp.ready",
|
||||
Self::AgentMcpFailed(_) => "agent.mcp.failed",
|
||||
Self::AgentMemoryLoaded(_) => "agent.memory.loaded",
|
||||
Self::AgentSkillsDiscovered(_) => "agent.skills.discovered",
|
||||
Self::AgentSkillActivated(_) => "agent.skill.activated",
|
||||
Self::TodoCreated(_) => "todo.created",
|
||||
Self::TodoUpdated(_) => "todo.updated",
|
||||
Self::TodoDeleted(_) => "todo.deleted",
|
||||
|
|
@ -680,6 +689,9 @@ fn is_known_event_name(event: &str) -> bool {
|
|||
| "agent.sub.closed"
|
||||
| "agent.mcp.ready"
|
||||
| "agent.mcp.failed"
|
||||
| "agent.memory.loaded"
|
||||
| "agent.skills.discovered"
|
||||
| "agent.skill.activated"
|
||||
| "todo.created"
|
||||
| "todo.updated"
|
||||
| "todo.deleted"
|
||||
|
|
@ -1983,4 +1995,138 @@ mod tests {
|
|||
other => panic!("expected TodoDeleted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_memory_loaded_serializes_with_canonical_name() {
|
||||
let body = EventBody::AgentMemoryLoaded(AgentMemoryLoadedProps {
|
||||
provider_profile: "anthropic".to_string(),
|
||||
files: vec![AgentMemoryFileProps {
|
||||
path: "/repo/AGENTS.md".to_string(),
|
||||
byte_count: 100,
|
||||
loaded_bytes: 100,
|
||||
truncated: false,
|
||||
}],
|
||||
total_loaded_bytes: 100,
|
||||
budget_bytes: 32768,
|
||||
visit: 1,
|
||||
});
|
||||
let value = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(value["event"], "agent.memory.loaded");
|
||||
assert_eq!(value["properties"]["provider_profile"], "anthropic");
|
||||
assert_eq!(value["properties"]["files"][0]["path"], "/repo/AGENTS.md");
|
||||
assert_eq!(value["properties"]["budget_bytes"], 32768);
|
||||
assert!(
|
||||
value["properties"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("content")
|
||||
.is_none(),
|
||||
"memory event must not include file content"
|
||||
);
|
||||
let _ = serde_json::from_value::<EventBody>(value).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_skills_discovered_serializes_with_canonical_name() {
|
||||
let body = EventBody::AgentSkillsDiscovered(AgentSkillsDiscoveredProps {
|
||||
provider_profile: "openai".to_string(),
|
||||
source_dirs: vec!["/repo/.fabro/skills".to_string()],
|
||||
skills: vec![AgentSkillSummary {
|
||||
name: "commit".to_string(),
|
||||
description: "Create a commit".to_string(),
|
||||
}],
|
||||
visit: 2,
|
||||
});
|
||||
let value = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(value["event"], "agent.skills.discovered");
|
||||
assert_eq!(value["properties"]["skills"][0]["name"], "commit");
|
||||
let _: EventBody = serde_json::from_value(value).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_skill_activated_serializes_source_variants() {
|
||||
let slash = EventBody::AgentSkillActivated(AgentSkillActivatedProps {
|
||||
skill_name: "commit".to_string(),
|
||||
source: AgentSkillActivationSource::Slash,
|
||||
visit: 3,
|
||||
});
|
||||
let value = serde_json::to_value(&slash).unwrap();
|
||||
assert_eq!(value["event"], "agent.skill.activated");
|
||||
assert_eq!(value["properties"]["source"], "slash");
|
||||
|
||||
let tool = EventBody::AgentSkillActivated(AgentSkillActivatedProps {
|
||||
skill_name: "commit".to_string(),
|
||||
source: AgentSkillActivationSource::Tool,
|
||||
visit: 4,
|
||||
});
|
||||
let value = serde_json::to_value(&tool).unwrap();
|
||||
assert_eq!(value["properties"]["source"], "tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_mcp_ready_deserializes_legacy_payload_without_tools() {
|
||||
let value = json!({
|
||||
"id": "evt_mcp_ready",
|
||||
"ts": "2026-05-22T12:00:00.000Z",
|
||||
"run_id": fixtures::RUN_1,
|
||||
"event": "agent.mcp.ready",
|
||||
"properties": {
|
||||
"server_name": "github",
|
||||
"tool_count": 2,
|
||||
"visit": 1
|
||||
}
|
||||
});
|
||||
let parsed = RunEvent::from_value(value).unwrap();
|
||||
match parsed.body {
|
||||
EventBody::AgentMcpReady(props) => {
|
||||
assert_eq!(props.server_name, "github");
|
||||
assert_eq!(props.tool_count, 2);
|
||||
assert!(props.tools.is_empty());
|
||||
assert_eq!(props.visit, 1);
|
||||
}
|
||||
other => panic!("expected AgentMcpReady body, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_mcp_ready_serializes_with_tool_summaries() {
|
||||
let body = EventBody::AgentMcpReady(AgentMcpReadyProps {
|
||||
server_name: "github".to_string(),
|
||||
tool_count: 1,
|
||||
tools: vec![AgentMcpToolSummary {
|
||||
name: "mcp__github__create_issue".to_string(),
|
||||
original_name: "create_issue".to_string(),
|
||||
}],
|
||||
visit: 1,
|
||||
});
|
||||
let value = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(value["event"], "agent.mcp.ready");
|
||||
assert_eq!(
|
||||
value["properties"]["tools"][0]["name"],
|
||||
"mcp__github__create_issue"
|
||||
);
|
||||
assert_eq!(
|
||||
value["properties"]["tools"][0]["original_name"],
|
||||
"create_issue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_mcp_ready_omits_tools_when_empty() {
|
||||
let body = EventBody::AgentMcpReady(AgentMcpReadyProps {
|
||||
server_name: "github".to_string(),
|
||||
tool_count: 0,
|
||||
tools: Vec::new(),
|
||||
visit: 1,
|
||||
});
|
||||
let value = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
value["properties"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("tools")
|
||||
.is_none(),
|
||||
"empty tools should be omitted for legacy parity"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use ::fabro_types::{
|
|||
EventBody, RunControlAction, RunEvent, RunId, StageOutcome, run_event as fabro_types,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use fabro_agent::{AgentEvent, SandboxEvent};
|
||||
use fabro_agent::{AgentEvent, SandboxEvent, SkillActivationSource};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::Event;
|
||||
|
|
@ -722,9 +722,17 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
|||
AgentEvent::McpServerReady {
|
||||
server_name,
|
||||
tool_count,
|
||||
tools,
|
||||
} => EventBody::AgentMcpReady(fabro_types::AgentMcpReadyProps {
|
||||
server_name: server_name.clone(),
|
||||
tool_count: *tool_count,
|
||||
tools: tools
|
||||
.iter()
|
||||
.map(|tool| fabro_types::AgentMcpToolSummary {
|
||||
name: tool.name.clone(),
|
||||
original_name: tool.original_name.clone(),
|
||||
})
|
||||
.collect(),
|
||||
visit: *visit,
|
||||
}),
|
||||
AgentEvent::McpServerFailed { server_name, error } => {
|
||||
|
|
@ -734,6 +742,56 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
|||
visit: *visit,
|
||||
})
|
||||
}
|
||||
AgentEvent::MemoryLoaded {
|
||||
provider_profile,
|
||||
files,
|
||||
total_loaded_bytes,
|
||||
budget_bytes,
|
||||
} => EventBody::AgentMemoryLoaded(fabro_types::AgentMemoryLoadedProps {
|
||||
provider_profile: provider_profile.clone(),
|
||||
total_loaded_bytes: *total_loaded_bytes,
|
||||
files: files
|
||||
.iter()
|
||||
.map(|file| fabro_types::AgentMemoryFileProps {
|
||||
path: file.path.clone(),
|
||||
byte_count: file.byte_count,
|
||||
loaded_bytes: file.loaded_bytes,
|
||||
truncated: file.truncated,
|
||||
})
|
||||
.collect(),
|
||||
budget_bytes: *budget_bytes,
|
||||
visit: *visit,
|
||||
}),
|
||||
AgentEvent::SkillsDiscovered {
|
||||
provider_profile,
|
||||
source_dirs,
|
||||
skills,
|
||||
} => EventBody::AgentSkillsDiscovered(fabro_types::AgentSkillsDiscoveredProps {
|
||||
provider_profile: provider_profile.clone(),
|
||||
source_dirs: source_dirs.clone(),
|
||||
skills: skills
|
||||
.iter()
|
||||
.map(|skill| fabro_types::AgentSkillSummary {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
})
|
||||
.collect(),
|
||||
visit: *visit,
|
||||
}),
|
||||
AgentEvent::SkillActivated { skill_name, source } => {
|
||||
EventBody::AgentSkillActivated(fabro_types::AgentSkillActivatedProps {
|
||||
skill_name: skill_name.clone(),
|
||||
source: match source {
|
||||
SkillActivationSource::Slash => {
|
||||
fabro_types::AgentSkillActivationSource::Slash
|
||||
}
|
||||
SkillActivationSource::Tool => {
|
||||
fabro_types::AgentSkillActivationSource::Tool
|
||||
}
|
||||
},
|
||||
visit: *visit,
|
||||
})
|
||||
}
|
||||
AgentEvent::TodoCreated(props) => EventBody::TodoCreated(props.clone()),
|
||||
AgentEvent::TodoUpdated(props) => EventBody::TodoUpdated(props.clone()),
|
||||
AgentEvent::TodoDeleted(props) => EventBody::TodoDeleted(props.clone()),
|
||||
|
|
@ -742,7 +800,6 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
|||
| AgentEvent::TextDelta { .. }
|
||||
| AgentEvent::ReasoningDelta { .. }
|
||||
| AgentEvent::ToolCallOutputDelta { .. }
|
||||
| AgentEvent::SkillExpanded { .. }
|
||||
| AgentEvent::SessionStarted { .. }
|
||||
| AgentEvent::SessionEnded => panic!(
|
||||
"agent event should not be converted through the stage-scoped Event::Agent wrapper"
|
||||
|
|
@ -1311,7 +1368,10 @@ mod tests {
|
|||
RunProvenance, StageId, SystemActorKind, fixtures, run_event as fabro_types,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use fabro_agent::{AgentEvent, SandboxEvent};
|
||||
use fabro_agent::{
|
||||
AgentEvent, McpToolSummary, MemoryFileSummary, SandboxEvent, SkillActivationSource,
|
||||
SkillSummary,
|
||||
};
|
||||
use fabro_llm::types::TokenCounts as LlmTokenCounts;
|
||||
use fabro_model::{ModelRef, ProviderId};
|
||||
|
||||
|
|
@ -2262,4 +2322,181 @@ mod tests {
|
|||
let actor = stored.actor.as_ref().expect("actor set");
|
||||
assert_eq!(actor, &user_principal("alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_memory_loaded_maps_to_typed_event_body() {
|
||||
let stored = to_run_event(&fixtures::RUN_1, &Event::Agent {
|
||||
stage: "code".to_string(),
|
||||
visit: 3,
|
||||
event: AgentEvent::MemoryLoaded {
|
||||
provider_profile: "anthropic".to_string(),
|
||||
files: vec![MemoryFileSummary {
|
||||
path: "/repo/AGENTS.md".to_string(),
|
||||
byte_count: 200,
|
||||
loaded_bytes: 200,
|
||||
truncated: false,
|
||||
}],
|
||||
total_loaded_bytes: 200,
|
||||
budget_bytes: 32768,
|
||||
},
|
||||
session_id: Some("ses_1".to_string()),
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
assert_eq!(stored.event_name(), "agent.memory.loaded");
|
||||
match stored.body {
|
||||
EventBody::AgentMemoryLoaded(props) => {
|
||||
assert_eq!(props.visit, 3);
|
||||
assert_eq!(props.provider_profile, "anthropic");
|
||||
assert_eq!(props.budget_bytes, 32768);
|
||||
assert_eq!(props.total_loaded_bytes, 200);
|
||||
assert_eq!(props.files.len(), 1);
|
||||
assert_eq!(props.files[0].path, "/repo/AGENTS.md");
|
||||
assert_eq!(props.files[0].byte_count, 200);
|
||||
assert_eq!(props.files[0].loaded_bytes, 200);
|
||||
assert!(!props.files[0].truncated);
|
||||
}
|
||||
other => panic!("expected AgentMemoryLoaded body, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_memory_loaded_payload_excludes_file_contents() {
|
||||
let stored = to_run_event(&fixtures::RUN_1, &Event::Agent {
|
||||
stage: "code".to_string(),
|
||||
visit: 1,
|
||||
event: AgentEvent::MemoryLoaded {
|
||||
provider_profile: "openai".to_string(),
|
||||
files: vec![MemoryFileSummary {
|
||||
path: "/repo/AGENTS.md".to_string(),
|
||||
byte_count: 100,
|
||||
loaded_bytes: 100,
|
||||
truncated: false,
|
||||
}],
|
||||
total_loaded_bytes: 100,
|
||||
budget_bytes: 32768,
|
||||
},
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
let serialized = serde_json::to_string(&stored.body).unwrap();
|
||||
assert!(
|
||||
!serialized.contains("content"),
|
||||
"memory event payload must not contain file content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_skills_discovered_maps_to_typed_event_body() {
|
||||
let stored = to_run_event(&fixtures::RUN_1, &Event::Agent {
|
||||
stage: "code".to_string(),
|
||||
visit: 2,
|
||||
event: AgentEvent::SkillsDiscovered {
|
||||
provider_profile: "anthropic".to_string(),
|
||||
source_dirs: vec!["/repo/.fabro/skills".to_string()],
|
||||
skills: vec![SkillSummary {
|
||||
name: "commit".to_string(),
|
||||
description: "Make a commit".to_string(),
|
||||
}],
|
||||
},
|
||||
session_id: Some("ses_1".to_string()),
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
assert_eq!(stored.event_name(), "agent.skills.discovered");
|
||||
match stored.body {
|
||||
EventBody::AgentSkillsDiscovered(props) => {
|
||||
assert_eq!(props.visit, 2);
|
||||
assert_eq!(props.provider_profile, "anthropic");
|
||||
assert_eq!(props.source_dirs, vec!["/repo/.fabro/skills".to_string()]);
|
||||
assert_eq!(props.skills.len(), 1);
|
||||
assert_eq!(props.skills[0].name, "commit");
|
||||
assert_eq!(props.skills[0].description, "Make a commit");
|
||||
}
|
||||
other => panic!("expected AgentSkillsDiscovered body, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_skill_activated_maps_slash_and_tool_sources() {
|
||||
let slash = to_run_event(&fixtures::RUN_1, &Event::Agent {
|
||||
stage: "code".to_string(),
|
||||
visit: 1,
|
||||
event: AgentEvent::SkillActivated {
|
||||
skill_name: "commit".to_string(),
|
||||
source: SkillActivationSource::Slash,
|
||||
},
|
||||
session_id: Some("ses_1".to_string()),
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
assert_eq!(slash.event_name(), "agent.skill.activated");
|
||||
match slash.body {
|
||||
EventBody::AgentSkillActivated(props) => {
|
||||
assert_eq!(props.visit, 1);
|
||||
assert_eq!(props.skill_name, "commit");
|
||||
assert_eq!(props.source, fabro_types::AgentSkillActivationSource::Slash);
|
||||
}
|
||||
other => panic!("expected AgentSkillActivated body, got {other:?}"),
|
||||
}
|
||||
|
||||
let tool = to_run_event(&fixtures::RUN_1, &Event::Agent {
|
||||
stage: "code".to_string(),
|
||||
visit: 4,
|
||||
event: AgentEvent::SkillActivated {
|
||||
skill_name: "review".to_string(),
|
||||
source: SkillActivationSource::Tool,
|
||||
},
|
||||
session_id: None,
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
match tool.body {
|
||||
EventBody::AgentSkillActivated(props) => {
|
||||
assert_eq!(props.visit, 4);
|
||||
assert_eq!(props.skill_name, "review");
|
||||
assert_eq!(props.source, fabro_types::AgentSkillActivationSource::Tool);
|
||||
}
|
||||
other => panic!("expected AgentSkillActivated body, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_mcp_ready_carries_tool_summaries_and_visit() {
|
||||
let stored = to_run_event(&fixtures::RUN_1, &Event::Agent {
|
||||
stage: "code".to_string(),
|
||||
visit: 5,
|
||||
event: AgentEvent::McpServerReady {
|
||||
server_name: "github".to_string(),
|
||||
tool_count: 2,
|
||||
tools: vec![
|
||||
McpToolSummary {
|
||||
name: "mcp__github__create_issue".to_string(),
|
||||
original_name: "create_issue".to_string(),
|
||||
},
|
||||
McpToolSummary {
|
||||
name: "mcp__github__list_issues".to_string(),
|
||||
original_name: "list_issues".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
session_id: Some("ses_1".to_string()),
|
||||
parent_session_id: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
assert_eq!(stored.event_name(), "agent.mcp.ready");
|
||||
match stored.body {
|
||||
EventBody::AgentMcpReady(props) => {
|
||||
assert_eq!(props.visit, 5);
|
||||
assert_eq!(props.server_name, "github");
|
||||
assert_eq!(props.tool_count, 2);
|
||||
assert_eq!(props.tools.len(), 2);
|
||||
assert_eq!(props.tools[0].name, "mcp__github__create_issue");
|
||||
assert_eq!(props.tools[0].original_name, "create_issue");
|
||||
assert_eq!(props.tools[1].name, "mcp__github__list_issues");
|
||||
}
|
||||
other => panic!("expected AgentMcpReady body, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@ pub fn event_name(event: &Event) -> &'static str {
|
|||
AgentEvent::Warning { .. } => "agent.warning",
|
||||
AgentEvent::LoopDetected => "agent.loop.detected",
|
||||
AgentEvent::TurnLimitReached { .. } => "agent.turn.limit",
|
||||
AgentEvent::SkillExpanded { .. } => "agent.skill.expanded",
|
||||
AgentEvent::SteeringInjected { .. } => "agent.steering.injected",
|
||||
AgentEvent::CompactionStarted { .. } => "agent.compaction.started",
|
||||
AgentEvent::CompactionCompleted { .. } => "agent.compaction.completed",
|
||||
|
|
@ -89,6 +88,9 @@ pub fn event_name(event: &Event) -> &'static str {
|
|||
AgentEvent::SubAgentClosed { .. } => "agent.sub.closed",
|
||||
AgentEvent::McpServerReady { .. } => "agent.mcp.ready",
|
||||
AgentEvent::McpServerFailed { .. } => "agent.mcp.failed",
|
||||
AgentEvent::MemoryLoaded { .. } => "agent.memory.loaded",
|
||||
AgentEvent::SkillsDiscovered { .. } => "agent.skills.discovered",
|
||||
AgentEvent::SkillActivated { .. } => "agent.skill.activated",
|
||||
AgentEvent::TodoCreated(_) => "todo.created",
|
||||
AgentEvent::TodoUpdated(_) => "todo.updated",
|
||||
AgentEvent::TodoDeleted(_) => "todo.deleted",
|
||||
|
|
|
|||
|
|
@ -94,7 +94,12 @@ impl Handler for PromptHandler {
|
|||
if docs.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(docs.join("\n\n"))
|
||||
Some(
|
||||
docs.into_iter()
|
||||
.map(|doc| doc.content)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n"),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue