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:
fabro-sh-0530[bot] 2026-05-22 19:06:00 -04:00 committed by GitHub
parent 0754f1ca4a
commit a2e2cbc7ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1176 additions and 76 deletions

View file

@ -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 |

View file

@ -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(

View file

@ -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");
}
}

View file

@ -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");
}
}
}
}

View file

@ -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())
})
}),

View file

@ -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 { .. }

View file

@ -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());
}
}

View file

@ -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,

View file

@ -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,
}

View file

@ -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"
);
}
}

View file

@ -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:?}"),
}
}
}

View file

@ -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",

View file

@ -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