diff --git a/run.json b/run.json index 82cb04dfe..e3b096d32 100644 --- a/run.json +++ b/run.json @@ -517,7 +517,7 @@ "kind": "running" }, "status_updated_at": "2026-05-22T18:57:01.494747Z", - "last_event_at": "2026-05-22T19:23:33.678929Z", + "last_event_at": "2026-05-22T19:38:59.232726Z", "pending_control": null, "checkpoints": [ { @@ -765,9 +765,9 @@ } }, { - "seq": 0, + "seq": 495, "checkpoint": { - "timestamp": "2026-05-22T19:23:33.870050Z", + "timestamp": "2026-05-22T19:23:37.878773Z", "current_node": "implement", "completed_nodes": [ "start", @@ -778,31 +778,170 @@ ], "node_retries": {}, "context_values": { + "graph.rankdir": "LR", + "response.implement": "Let me do a quick sanity check on the plan's \"final sanity checks\":\n\n1. ✅ Memory events never contain file contents — verified by test `agent_memory_loaded_payload_excludes_file_contents` and structural design.\n2. ✅ Skills discovered and memory loaded emitted even for empty lists — verified by tests `initialize_emits_memory_loaded_event_with_empty_files_when_no_memory` and `initialize_emits_skills_discovered_event_when_no_skills`.\n3. ✅ Skill activation is persisted rather than filtered as streaming noise — `SkillActivated` is not in `is_streaming_noise()` and has conversion + name mapping.\n4. ✅ `agent.mcp.ready` backwards-compatible — verified by `agent_mcp_ready_deserializes_legacy_payload_without_tools` test and `#[serde(default, skip_serializing_if = \"Vec::is_empty\")]`.\n5. ✅ Docs match the serialized event names and payload shapes.\n\nSummary of changes:\n\n**Task 1 (fabro-types):** Added `AgentMemoryLoadedProps`, `AgentMemoryFileProps`, `AgentSkillsDiscoveredProps`, `AgentSkillSummary`, `AgentSkillActivationSource`, `AgentSkillActivatedProps`, `AgentMcpToolSummary`. Extended `AgentMcpReadyProps` with a backwards-compatible `tools` field. Added 3 new `EventBody` variants (`AgentMemoryLoaded`, `AgentSkillsDiscovered`, `AgentSkillActivated`) plus the legacy whitelist entries. Added 5 serde tests.\n\n**Task 2 (fabro-agent / fabro-workflow):** Added internal `AgentEvent::MemoryLoaded`, `SkillsDiscovered`, `SkillActivated` variants plus support structs (`MemoryFileSummary`, `SkillSummary`, `McpToolSummary`, `SkillActivationSource`). Extended `AgentEvent::McpServerReady` with `tools`. Added tracing for the new variants. Updated workflow `convert.rs` and `names.rs`. Added 4 conversion tests.\n\n**Task 3 (memory):** Changed `discover_memory` to return `Vec` with `{path, content, byte_count, loaded_bytes, truncated}`. `Session::initialize` emits `MemoryLoaded` immediately after discovery, always (even for empty results). The prompt assembly receives content-only strings. Memory test added for truncation metadata.\n\n**Task 4 (skills):** `Session::initialize` emits `SkillsDiscovered` (always). Slash expansion now emits `SkillActivated { Slash }` instead of `SkillExpanded`. `make_use_skill_tool` emits `SkillActivated { Tool }` only on successful lookup. Failed lookups do not emit. Added 4 session tests.\n\n**Task 5 (MCP):** Added `McpConnectionManager::tool_summaries_for_server` that filters/sorts by qualified name. `Session::initialize` includes the summaries in `McpServerReady`. Existing MCP e2e test now asserts the names-only summary content. Added an mcp unit test.\n\n**Task 6 (docs):** Updated `docs/internal/events.md` with the enriched `agent.mcp.ready` payload, three new sections for `agent.memory.loaded`, `agent.skills.discovered`, `agent.skill.activated`, and a note about the retired `agent.skill.expanded` notification.\n\n**Task 7:** All 1841 tests in `fabro-agent`, `fabro-workflow`, `fabro-types`, `fabro-mcp` pass; workspace clippy clean under nightly-2026-04-14; formatting checked.", + "internal.retry_count.toolchain": 0, + "internal.retry_count.preflight_lint": 0, + "thread.preflight_compile.current_node": "preflight_lint", + "internal.retry_count.implement": 0, + "internal.retry_count.start": 0, + "internal.retry_count.preflight_compile": 0, + "failure_class": "", + "internal.thread_id": "preflight_lint", + "current_node": "implement", + "graph.goal": "# Agent Context Observability Events Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Add durable API/backend events that report loaded memory files, discovered and activated skills, and per-server MCP tool names for agent runs.\n\n**Architecture:** Keep this API-backend scoped. Emit typed `AgentEvent` variants from the existing `fabro-agent` initialization and skill activation paths, convert them through `fabro-workflow` into durable `fabro-types` run events, and document the event contracts. Do not add run projection fields in this pass; consumers can read the event stream/history.\n\n**Tech Stack:** Rust, Serde, Fabro agent/session events, Fabro workflow event conversion, Fabro MCP connection manager, `cargo nextest`.\n\n---\n\n## Scope\n\nImplement these event changes:\n\n- Add `agent.memory.loaded` with memory file paths, byte counts, loaded byte counts, truncation flags, provider profile, total loaded bytes, and budget bytes.\n- Add `agent.skills.discovered` with source directories, provider profile, and sorted skill summaries.\n- Add persisted `agent.skill.activated` for slash skill expansion and successful `use_skill` tool calls.\n- Enrich `agent.mcp.ready` with names-only tool summaries: qualified tool name and original server tool name.\n\nDo not implement ACP-native equivalents in this pass. Do not include memory file contents in any event payload. Do not include MCP tool descriptions or schemas.\n\n## Existing Patterns To Follow\n\n- Read `docs/internal/events-strategy.md` before changing event variants, names, conversion, or progress JSONL behavior.\n- Read `docs/internal/testing-strategy.md` before adding or reorganizing tests.\n- Follow the current `AgentEvent` flow:\n - `lib/crates/fabro-agent/src/types.rs`\n - `lib/crates/fabro-agent/src/session.rs`\n - `lib/crates/fabro-workflow/src/handler/llm/api.rs`\n - `lib/crates/fabro-workflow/src/event/convert.rs`\n - `lib/crates/fabro-workflow/src/event/names.rs`\n - `lib/crates/fabro-types/src/run_event/agent.rs`\n - `lib/crates/fabro-types/src/run_event/mod.rs`\n- Follow Rust import style from `AGENTS.md`: import types by name, import functions through their parent module, and avoid glob imports in production code.\n\n## File Map\n\n- Modify `lib/crates/fabro-types/src/run_event/agent.rs`: add new prop structs and extend `AgentMcpReadyProps`.\n- Modify `lib/crates/fabro-types/src/run_event/mod.rs`: add `EventBody` variants for the new event names.\n- Modify `lib/crates/fabro-agent/src/types.rs`: add internal `AgentEvent` variants, trace output, and noise filtering decisions.\n- Modify `lib/crates/fabro-agent/src/memory.rs`: return memory content plus metadata instead of bare strings.\n- Modify `lib/crates/fabro-agent/src/session.rs`: emit memory, skills, skill activation, and enriched MCP events.\n- Modify `lib/crates/fabro-agent/src/skills.rs`: emit tool-sourced skill activation from `use_skill`.\n- Modify `lib/crates/fabro-mcp/src/connection_manager.rs`: expose or support deterministic names-only tool summaries per server.\n- Modify `lib/crates/fabro-workflow/src/event/convert.rs`: convert new agent events to durable event bodies.\n- Modify `lib/crates/fabro-workflow/src/event/names.rs`: add event names.\n- Modify `lib/crates/fabro-workflow/src/event/events.rs` only if the agent event name mapping also lives there for these variants.\n- Modify `lib/crates/fabro-workflow/src/event/stored_fields.rs` only if a new event needs non-standard stored fields; otherwise rely on existing `Event::Agent` handling.\n- Modify `docs/internal/events.md`: document new event shapes and the richer MCP payload.\n- Add or update tests in `lib/crates/fabro-agent`, `lib/crates/fabro-mcp`, `lib/crates/fabro-types`, and `lib/crates/fabro-workflow`.\n\n---\n\n### Task 1: Add Typed Durable Event Contracts\n\n**Files:**\n- Modify: `lib/crates/fabro-types/src/run_event/agent.rs`\n- Modify: `lib/crates/fabro-types/src/run_event/mod.rs`\n- Test: existing `fabro-types` run event serde tests, or add focused coverage near the existing run event tests.\n\n- [ ] **Step 1: Add agent memory props**\n\nAdd event prop structs with this shape:\n\n```rust\npub struct AgentMemoryLoadedProps {\n pub provider_profile: String,\n pub files: Vec,\n pub total_loaded_bytes: usize,\n pub budget_bytes: usize,\n pub visit: u32,\n}\n\npub struct AgentMemoryFileProps {\n pub path: String,\n pub byte_count: usize,\n pub loaded_bytes: usize,\n pub truncated: bool,\n}\n```\n\n- [ ] **Step 2: Add skill props**\n\nAdd skill discovery and activation props:\n\n```rust\npub struct AgentSkillsDiscoveredProps {\n pub provider_profile: String,\n pub source_dirs: Vec,\n pub skills: Vec,\n pub visit: u32,\n}\n\npub struct AgentSkillSummary {\n pub name: String,\n pub description: String,\n}\n\npub enum AgentSkillActivationSource {\n Slash,\n Tool,\n}\n\npub struct AgentSkillActivatedProps {\n pub skill_name: String,\n pub source: AgentSkillActivationSource,\n pub visit: u32,\n}\n```\n\nUse serde names `slash` and `tool` for `AgentSkillActivationSource`. If a local enum string pattern already exists, follow that pattern.\n\n- [ ] **Step 3: Extend MCP ready props**\n\nExtend `AgentMcpReadyProps` with a backwards-compatible field:\n\n```rust\n#[serde(default, skip_serializing_if = \"Vec::is_empty\")]\npub tools: Vec,\n```\n\nAdd:\n\n```rust\npub struct AgentMcpToolSummary {\n pub name: String,\n pub original_name: String,\n}\n```\n\n- [ ] **Step 4: Add EventBody variants**\n\nAdd `EventBody` variants using these serialized event names:\n\n- `agent.memory.loaded`\n- `agent.skills.discovered`\n- `agent.skill.activated`\n\nKeep existing `agent.mcp.ready` name unchanged and only enrich its props.\n\n- [ ] **Step 5: Add serde tests**\n\nCover:\n\n- New event names serialize to the expected dot names.\n- `AgentSkillActivationSource` serializes as `slash` and `tool`.\n- Old `agent.mcp.ready` JSON without `tools` still deserializes with `tools == []`.\n\n---\n\n### Task 2: Add Internal Agent Events And Conversion\n\n**Files:**\n- Modify: `lib/crates/fabro-agent/src/types.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/convert.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/names.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/events.rs` if needed by the existing name mapping.\n- Test: `lib/crates/fabro-workflow` event conversion tests.\n\n- [ ] **Step 1: Add internal AgentEvent variants**\n\nAdd variants equivalent to:\n\n```rust\nMemoryLoaded {\n provider_profile: String,\n files: Vec,\n total_loaded_bytes: usize,\n budget_bytes: usize,\n}\n\nSkillsDiscovered {\n provider_profile: String,\n source_dirs: Vec,\n skills: Vec,\n}\n\nSkillActivated {\n skill_name: String,\n source: SkillActivationSource,\n}\n\nMcpServerReady {\n server_name: String,\n tool_count: usize,\n tools: Vec,\n}\n```\n\nPrefer small shared internal structs near `AgentEvent` if that matches the existing file organization.\n\n- [ ] **Step 2: Persist skill activation**\n\nDo not classify `SkillActivated` as streaming noise. The existing `SkillExpanded` event is currently filtered before persistence; replace slash expansion emissions with `SkillActivated { source: Slash }` or keep `SkillExpanded` internal-only if removing it would create unnecessary churn.\n\n- [ ] **Step 3: Add trace behavior**\n\nUpdate `AgentEvent::trace` so the new events emit concise tracing summaries:\n\n- memory loaded: profile, file count, total loaded bytes, budget bytes\n- skills discovered: profile, skill count, source dir count\n- skill activated: name and source\n- MCP ready: server, count, and summary count\n\n- [ ] **Step 4: Convert to durable events**\n\nUpdate `fabro-workflow` event conversion so the new agent events map to the new `fabro-types` props and include `visit`.\n\n- [ ] **Step 5: Add conversion tests**\n\nCover each new event with a focused conversion assertion that checks:\n\n- durable event name\n- `visit`\n- core fields\n- no memory content in the converted payload\n\n---\n\n### Task 3: Emit Memory Loaded Metadata\n\n**Files:**\n- Modify: `lib/crates/fabro-agent/src/memory.rs`\n- Modify: `lib/crates/fabro-agent/src/session.rs`\n- Test: relevant `fabro-agent` memory/session tests.\n\n- [ ] **Step 1: Change memory discovery return type**\n\nChange memory discovery from bare `Vec` to a document type carrying both prompt content and event metadata:\n\n```rust\npub struct MemoryDocument {\n pub path: String,\n pub content: String,\n pub byte_count: usize,\n pub loaded_bytes: usize,\n pub truncated: bool,\n}\n```\n\nKeep existing behavior unchanged:\n\n- provider profile filename candidates stay the same\n- root-to-working-dir walk stays the same\n- content dedupe stays the same\n- empty files are skipped\n- total budget remains 32 KiB\n- truncated content keeps the existing truncation marker\n\n- [ ] **Step 2: Preserve prompt assembly behavior**\n\nAdjust session/profile prompt assembly to pass only memory contents where prompt assembly expects memory text. The system prompt should be byte-for-byte equivalent except where existing tests allow non-semantic ordering differences.\n\n- [ ] **Step 3: Emit agent.memory.loaded**\n\nIn `Session::initialize()`, emit `AgentEvent::MemoryLoaded` immediately after memory discovery, before skills and MCP initialization.\n\nEmit the event even when no memory files are loaded. That lets consumers distinguish \"no memory\" from \"not reported.\"\n\n- [ ] **Step 4: Add memory tests**\n\nCover:\n\n- loaded file path appears in event metadata\n- `byte_count` is the original file byte count\n- `loaded_bytes` reflects bytes actually loaded into the prompt budget\n- `truncated` is true only for truncated files\n- event payload never contains memory file contents\n- empty discovery still emits a memory-loaded event with `files == []`\n\n---\n\n### Task 4: Emit Skills Discovered And Skill Activated\n\n**Files:**\n- Modify: `lib/crates/fabro-agent/src/session.rs`\n- Modify: `lib/crates/fabro-agent/src/skills.rs`\n- Test: relevant `fabro-agent` skill/session tests.\n\n- [ ] **Step 1: Emit skills discovered**\n\nAfter `discover_skills(...)`, emit `AgentEvent::SkillsDiscovered` with:\n\n- `provider_profile`\n- `source_dirs`\n- sorted `skills: [{ name, description }]`\n\nEmit the event even when no skills are discovered.\n\n- [ ] **Step 2: Emit slash activation**\n\nWhere slash skill expansion currently emits or creates `SkillExpanded`, emit:\n\n```rust\nAgentEvent::SkillActivated {\n skill_name,\n source: SkillActivationSource::Slash,\n}\n```\n\n- [ ] **Step 3: Emit tool activation**\n\nIn `make_use_skill_tool`, use `ToolContext::emit_agent_event(...)` after a requested skill is found and before returning the skill template. Emit:\n\n```rust\nAgentEvent::SkillActivated {\n skill_name: name.to_string(),\n source: SkillActivationSource::Tool,\n}\n```\n\nDo not emit activation for failed `use_skill` lookups.\n\n- [ ] **Step 4: Add skill tests**\n\nCover:\n\n- discovery event includes all discovered skills sorted by name\n- discovery event includes configured source directories\n- empty discovery emits `skills == []`\n- slash expansion emits `source == slash`\n- successful `use_skill` emits `source == tool`\n- failed `use_skill` does not emit activation\n\n---\n\n### Task 5: Enrich agent.mcp.ready With Names-Only Tool Summaries\n\n**Files:**\n- Modify: `lib/crates/fabro-mcp/src/connection_manager.rs`\n- Modify: `lib/crates/fabro-agent/src/session.rs`\n- Test: relevant `fabro-mcp` or `fabro-agent` MCP tests.\n\n- [ ] **Step 1: Add deterministic tool summaries**\n\nExpose a helper on `McpConnectionManager` or compute in `Session` from `all_tools()`:\n\n- filter tools by `server_name`\n- return qualified tool name as `name`\n- return server-provided tool name as `original_name`\n- sort by qualified `name`\n\n- [ ] **Step 2: Enrich ready emissions**\n\nWhen emitting `AgentEvent::McpServerReady`, include the tool summaries for that server. Keep existing `server_name` and `tool_count`.\n\n- [ ] **Step 3: Add MCP tests**\n\nCover:\n\n- ready event includes only tools from the ready server\n- summaries are sorted by qualified name\n- `name` is the Fabro-qualified MCP tool name\n- `original_name` is the server-provided tool name\n- descriptions and input schemas are not included\n\n---\n\n### Task 6: Update Event Documentation\n\n**Files:**\n- Modify: `docs/internal/events.md`\n\n- [ ] **Step 1: Document new events**\n\nAdd sections for:\n\n- `agent.memory.loaded`\n- `agent.skills.discovered`\n- `agent.skill.activated`\n\nFor `agent.memory.loaded`, explicitly state that file contents are excluded.\n\n- [ ] **Step 2: Update MCP ready docs**\n\nUpdate `agent.mcp.ready` to show:\n\n```json\n{\n \"server_name\": \"github\",\n \"tool_count\": 2,\n \"tools\": [\n {\n \"name\": \"mcp__github__create_issue\",\n \"original_name\": \"create_issue\"\n }\n ],\n \"visit\": 1\n}\n```\n\n- [ ] **Step 3: Record skill event replacement**\n\nIf `agent.skill.expanded` remains in internal code or docs, mark it internal-only or replaced by `agent.skill.activated`.\n\n---\n\n### Task 7: Verify\n\n**Files:**\n- No new files unless test placement requires it.\n\n- [ ] **Step 1: Run focused tests**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-agent -p fabro-workflow -p fabro-types -p fabro-mcp\n```\n\n- [ ] **Step 2: Run formatting**\n\nRun:\n\n```bash\ncargo +nightly-2026-04-14 fmt --all\n```\n\n- [ ] **Step 3: Run clippy for touched crates or workspace**\n\nPrefer the workspace command if time permits:\n\n```bash\ncargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings\n```\n\n- [ ] **Step 4: Final sanity checks**\n\nConfirm:\n\n- memory events never contain file contents\n- skills discovered and memory loaded are emitted even for empty lists\n- skill activation is persisted rather than filtered as streaming noise\n- `agent.mcp.ready` remains backwards-compatible for old events without `tools`\n- docs match the serialized event names and payload shapes\n\n", + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "internal.work_dir": "/home/daytona/workspace/fabro", + "internal.node_visit_count": 1, + "last_response": "Let me do a quick sanity check on the plan's \"final sanity checks\":\n\n1. ✅ Memory events never contain file contents — verified by test `agent_memory_loaded_payload_excludes_file_contents` and stru", + "failure_signature": "", "internal.run_id": "01KS8GR1P97NYP93VVXWYCZHYZ", + "last_stage": "implement", + "thread.toolchain.current_node": "preflight_compile", + "outcome": "succeeded", + "internal.fidelity": "compact", + "thread.preflight_lint.current_node": "implement", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "thread.start.current_node": "toolchain" + }, + "node_outcomes": { + "implement": { + "status": "succeeded", + "context_updates": { + "last_response": "Let me do a quick sanity check on the plan's \"final sanity checks\":\n\n1. ✅ Memory events never contain file contents — verified by test `agent_memory_loaded_payload_excludes_file_contents` and stru", + "response.implement": "Let me do a quick sanity check on the plan's \"final sanity checks\":\n\n1. ✅ Memory events never contain file contents — verified by test `agent_memory_loaded_payload_excludes_file_contents` and structural design.\n2. ✅ Skills discovered and memory loaded emitted even for empty lists — verified by tests `initialize_emits_memory_loaded_event_with_empty_files_when_no_memory` and `initialize_emits_skills_discovered_event_when_no_skills`.\n3. ✅ Skill activation is persisted rather than filtered as streaming noise — `SkillActivated` is not in `is_streaming_noise()` and has conversion + name mapping.\n4. ✅ `agent.mcp.ready` backwards-compatible — verified by `agent_mcp_ready_deserializes_legacy_payload_without_tools` test and `#[serde(default, skip_serializing_if = \"Vec::is_empty\")]`.\n5. ✅ Docs match the serialized event names and payload shapes.\n\nSummary of changes:\n\n**Task 1 (fabro-types):** Added `AgentMemoryLoadedProps`, `AgentMemoryFileProps`, `AgentSkillsDiscoveredProps`, `AgentSkillSummary`, `AgentSkillActivationSource`, `AgentSkillActivatedProps`, `AgentMcpToolSummary`. Extended `AgentMcpReadyProps` with a backwards-compatible `tools` field. Added 3 new `EventBody` variants (`AgentMemoryLoaded`, `AgentSkillsDiscovered`, `AgentSkillActivated`) plus the legacy whitelist entries. Added 5 serde tests.\n\n**Task 2 (fabro-agent / fabro-workflow):** Added internal `AgentEvent::MemoryLoaded`, `SkillsDiscovered`, `SkillActivated` variants plus support structs (`MemoryFileSummary`, `SkillSummary`, `McpToolSummary`, `SkillActivationSource`). Extended `AgentEvent::McpServerReady` with `tools`. Added tracing for the new variants. Updated workflow `convert.rs` and `names.rs`. Added 4 conversion tests.\n\n**Task 3 (memory):** Changed `discover_memory` to return `Vec` with `{path, content, byte_count, loaded_bytes, truncated}`. `Session::initialize` emits `MemoryLoaded` immediately after discovery, always (even for empty results). The prompt assembly receives content-only strings. Memory test added for truncation metadata.\n\n**Task 4 (skills):** `Session::initialize` emits `SkillsDiscovered` (always). Slash expansion now emits `SkillActivated { Slash }` instead of `SkillExpanded`. `make_use_skill_tool` emits `SkillActivated { Tool }` only on successful lookup. Failed lookups do not emit. Added 4 session tests.\n\n**Task 5 (MCP):** Added `McpConnectionManager::tool_summaries_for_server` that filters/sorts by qualified name. `Session::initialize` includes the summaries in `McpServerReady`. Existing MCP e2e test now asserts the names-only summary content. Added an mcp unit test.\n\n**Task 6 (docs):** Updated `docs/internal/events.md` with the enriched `agent.mcp.ready` payload, three new sections for `agent.memory.loaded`, `agent.skills.discovered`, `agent.skill.activated`, and a note about the retired `agent.skill.expanded` notification.\n\n**Task 7:** All 1841 tests in `fabro-agent`, `fabro-workflow`, `fabro-types`, `fabro-mcp` pass; workspace clippy clean under nightly-2026-04-14; formatting checked.", + "last_stage": "implement" + }, + "notes": "Stage completed: implement", + "usage": { + "input": { + "usage": { + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "tokens": { + "input_tokens": 220090, + "output_tokens": 51681, + "reasoning_tokens": 0, + "cache_read_tokens": 24871894, + "cache_write_tokens": 231090 + } + }, + "facts": { + "algorithm": "anthropic", + "cache_write_5m_tokens": 231090, + "cache_write_1h_tokens": 0 + } + }, + "total_usd_micros": 16272734 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/docs/internal/events.md", + "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/lib.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/memory.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/session.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/skills.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/types.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-mcp/src/connection_manager.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-types/src/run_event/agent.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-types/src/run_event/mod.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event/convert.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event/names.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/prompt.rs" + ] + }, + "start": { + "status": "succeeded", + "usage": null + }, + "toolchain": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" + }, + "notes": "Script completed: 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", + "usage": null + }, + "preflight_lint": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", + "usage": null + }, + "preflight_compile": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo check -q --workspace 2>&1", + "usage": null + } + }, + "next_node_id": "simplify_opus", + "git_commit_sha": "265d1faf890788c75cea6f52e46f2af88899bc65", + "node_visits": { + "toolchain": 1, + "implement": 1, + "preflight_lint": 1, + "start": 1, + "preflight_compile": 1 + } + }, + "diff": { + "patch": "diff --git a/docs/internal/events.md b/docs/internal/events.md\nindex f9e99fd89..b6ed547d1 100644\n--- a/docs/internal/events.md\n+++ b/docs/internal/events.md\n@@ -1468,8 +1468,19 @@ Emitted when a sub-agent is spawned.\n \"node_id\": \"code\", \"node_label\": \"code\",\n \"session_id\": \"ses_abc\",\n \"properties\": {\n- \"server_name\": \"filesystem\",\n- \"tool_count\": 5\n+ \"server_name\": \"github\",\n+ \"tool_count\": 2,\n+ \"tools\": [\n+ {\n+ \"name\": \"mcp__github__create_issue\",\n+ \"original_name\": \"create_issue\"\n+ },\n+ {\n+ \"name\": \"mcp__github__list_issues\",\n+ \"original_name\": \"list_issues\"\n+ }\n+ ],\n+ \"visit\": 1\n }\n }\n ```\n@@ -1478,6 +1489,8 @@ Emitted when a sub-agent is spawned.\n |----------|------|-------------|\n | `server_name` | string | MCP server name |\n | `tool_count` | number | Number of tools available |\n+| `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. |\n+| `visit` | number | Stage visit count when the server became ready |\n \n ### `agent.mcp.failed`\n \n@@ -1499,6 +1512,121 @@ Emitted when a sub-agent is spawned.\n | `server_name` | string | MCP server name |\n | `error` | string | Error message |\n \n+### `agent.memory.loaded`\n+\n+Emitted once per session right after memory discovery, before skills and MCP\n+initialization. The event is always emitted, even when no memory files are\n+loaded (in which case `files` is an empty array). Memory file **contents are\n+deliberately excluded** from the payload to keep the durable event stream free\n+of project documentation bytes; consumers that need contents must read the\n+files themselves.\n+\n+```json\n+{\n+ \"id\": \"...\", \"ts\": \"...\", \"run_id\": \"...\",\n+ \"event\": \"agent.memory.loaded\",\n+ \"node_id\": \"code\", \"node_label\": \"code\",\n+ \"session_id\": \"ses_abc\",\n+ \"properties\": {\n+ \"provider_profile\": \"anthropic\",\n+ \"files\": [\n+ {\n+ \"path\": \"/repo/AGENTS.md\",\n+ \"byte_count\": 4096,\n+ \"loaded_bytes\": 4096,\n+ \"truncated\": false\n+ }\n+ ],\n+ \"total_loaded_bytes\": 4096,\n+ \"budget_bytes\": 32768,\n+ \"visit\": 1\n+ }\n+}\n+```\n+\n+| Property | Type | Description |\n+|----------|------|-------------|\n+| `provider_profile` | string | Active agent profile (`anthropic`, `openai`, `gemini`) |\n+| `files` | array | Discovered memory files. Empty when no memory was loaded. |\n+| `files[].path` | string | Absolute path of the memory file in the sandbox |\n+| `files[].byte_count` | number | Original file size in bytes |\n+| `files[].loaded_bytes` | number | Bytes actually loaded into the prompt budget |\n+| `files[].truncated` | boolean | `true` if the file was truncated to fit the budget |\n+| `total_loaded_bytes` | number | Sum of `files[].loaded_bytes` |\n+| `budget_bytes` | number | Total memory budget for the session (currently 32 KiB) |\n+| `visit` | number | Stage visit count |\n+\n+### `agent.skills.discovered`\n+\n+Emitted once per session right after skill discovery completes. The event is\n+always emitted, even when no skills are found (`skills` is an empty array).\n+Skills are sorted by name. `source_dirs` lists the directories that were\n+scanned in the configured precedence order.\n+\n+```json\n+{\n+ \"id\": \"...\", \"ts\": \"...\", \"run_id\": \"...\",\n+ \"event\": \"agent.skills.discovered\",\n+ \"node_id\": \"code\", \"node_label\": \"code\",\n+ \"session_id\": \"ses_abc\",\n+ \"properties\": {\n+ \"provider_profile\": \"anthropic\",\n+ \"source_dirs\": [\n+ \"/home/test/.fabro/skills\",\n+ \"/repo/.fabro/skills\",\n+ \"/repo/skills\"\n+ ],\n+ \"skills\": [\n+ { \"name\": \"commit\", \"description\": \"Make a commit\" }\n+ ],\n+ \"visit\": 1\n+ }\n+}\n+```\n+\n+| Property | Type | Description |\n+|----------|------|-------------|\n+| `provider_profile` | string | Active agent profile |\n+| `source_dirs` | array | Directories scanned for `SKILL.md` files (in precedence order) |\n+| `skills` | array | Discovered skills, sorted by `name`. Each entry is `{ name, description }`. |\n+| `visit` | number | Stage visit count |\n+\n+### `agent.skill.activated`\n+\n+Emitted whenever a skill is activated in the running session. Sources:\n+\n+- `slash` — the user input matched a `/skill-name` token and the skill template\n+ was expanded inline. This event replaces the previous internal-only\n+ `agent.skill.expanded` notification.\n+- `tool` — the model successfully called the `use_skill` tool and the skill\n+ template was returned. Failed `use_skill` lookups (unknown names, missing\n+ parameters) do **not** emit this event.\n+\n+```json\n+{\n+ \"id\": \"...\", \"ts\": \"...\", \"run_id\": \"...\",\n+ \"event\": \"agent.skill.activated\",\n+ \"node_id\": \"code\", \"node_label\": \"code\",\n+ \"session_id\": \"ses_abc\",\n+ \"properties\": {\n+ \"skill_name\": \"commit\",\n+ \"source\": \"slash\",\n+ \"visit\": 1\n+ }\n+}\n+```\n+\n+| Property | Type | Description |\n+|----------|------|-------------|\n+| `skill_name` | string | Name of the activated skill |\n+| `source` | string | `\"slash\"` for `/skill-name` expansion, `\"tool\"` for `use_skill` activations |\n+| `visit` | number | Stage visit count |\n+\n+> `agent.skill.expanded` is no longer surfaced as a durable run event. The\n+> internal `AgentEvent::SkillExpanded` variant remains classified as streaming\n+> noise and is not persisted; slash-skill expansion is reported through\n+> `agent.skill.activated` with `source == \"slash\"` instead.\n+\n ### `agent.failover`\n \n Emitted when the agent fails over to a different LLM provider/model.\n@@ -2307,4 +2435,4 @@ Emitted when the stall watchdog detects no progress.\n \n | Property | Type | Description |\n |----------|------|-------------|\n-| `idle_seconds` | number | Seconds since last activity |\n+| `idle_seconds` | number | Seconds since last activity |\n\\ No newline at end of file\ndiff --git a/lib/crates/fabro-agent/src/lib.rs b/lib/crates/fabro-agent/src/lib.rs\nindex 34de5062c..00be22526 100644\n--- a/lib/crates/fabro-agent/src/lib.rs\n+++ b/lib/crates/fabro-agent/src/lib.rs\n@@ -43,7 +43,7 @@ pub use fabro_types::SteeringMessage;\n pub use history::History;\n pub use local_sandbox::LocalSandbox;\n pub use loop_detection::detect_loop;\n-pub use memory::discover_memory;\n+pub use memory::{MemoryDocument, discover_memory};\n pub use profiles::{AnthropicProfile, EnvContext, GeminiProfile, OpenAiProfile};\n pub use read_before_write_sandbox::ReadBeforeWriteSandbox;\n pub use sandbox::{\n@@ -70,7 +70,10 @@ pub use tools::{\n make_shell_tool, make_shell_tool_with_config, make_write_file_tool, register_core_tools,\n };\n pub use truncation::{TruncationMode, truncate_lines, truncate_output, truncate_tool_output};\n-pub use types::{AgentEvent, Message, SessionEvent, SessionState};\n+pub use types::{\n+ AgentEvent, McpToolSummary, MemoryFileSummary, Message, SessionEvent, SessionState,\n+ SkillActivationSource, SkillSummary,\n+};\n \n #[cfg(test)]\n #[allow(\ndiff --git a/lib/crates/fabro-agent/src/memory.rs b/lib/crates/fabro-agent/src/memory.rs\nindex a86626846..07e59c6da 100644\n--- a/lib/crates/fabro-agent/src/memory.rs\n+++ b/lib/crates/fabro-agent/src/memory.rs\n@@ -7,7 +7,19 @@ use tracing::{debug, info, warn};\n use crate::error::{Error, InterruptReason};\n use crate::sandbox::Sandbox;\n \n-const BUDGET_BYTES: usize = 32768;\n+pub const BUDGET_BYTES: usize = 32768;\n+\n+/// One discovered memory file. `content` is what gets inlined into the\n+/// system prompt. The remaining fields describe the file for\n+/// observability and never carry the file's text.\n+#[derive(Debug, Clone, PartialEq)]\n+pub struct MemoryDocument {\n+ pub path: String,\n+ pub content: String,\n+ pub byte_count: usize,\n+ pub loaded_bytes: usize,\n+ pub truncated: bool,\n+}\n \n pub async fn discover_memory(\n env: &dyn Sandbox,\n@@ -15,7 +27,7 @@ pub async fn discover_memory(\n working_dir: &str,\n profile_kind: AgentProfileKind,\n cancel_token: &CancellationToken,\n-) -> Result, Error> {\n+) -> Result, Error> {\n let directories = build_directory_walk(git_root, working_dir);\n \n let candidate_filenames: Vec<&str> = match profile_kind {\n@@ -24,7 +36,7 @@ pub async fn discover_memory(\n AgentProfileKind::Gemini => vec![\"AGENTS.md\", \"GEMINI.md\"],\n };\n \n- let mut results = Vec::new();\n+ let mut results: Vec = Vec::new();\n let mut budget_remaining = BUDGET_BYTES;\n let mut seen_content = HashSet::new();\n \n@@ -47,28 +59,42 @@ pub async fn discover_memory(\n debug!(path = %path, \"Project doc duplicate content, skipping\");\n continue;\n }\n- if content.len() <= budget_remaining {\n- debug!(path = %path, size_bytes = content.len(), \"Project doc loaded\");\n- budget_remaining -= content.len();\n- results.push(content);\n+ let byte_count = content.len();\n+ if byte_count <= budget_remaining {\n+ debug!(path = %path, size_bytes = byte_count, \"Project doc loaded\");\n+ budget_remaining -= byte_count;\n+ results.push(MemoryDocument {\n+ path,\n+ content,\n+ byte_count,\n+ loaded_bytes: byte_count,\n+ truncated: false,\n+ });\n } else if budget_remaining > 0 {\n warn!(\n path = %path,\n- size_bytes = content.len(),\n+ size_bytes = byte_count,\n budget_remaining,\n \"Project doc truncated to fit budget\"\n );\n let truncated = truncate_to_budget(&content, budget_remaining);\n+ let loaded_bytes = truncated.len();\n budget_remaining = 0;\n- results.push(truncated);\n+ results.push(MemoryDocument {\n+ path,\n+ content: truncated,\n+ byte_count,\n+ loaded_bytes,\n+ truncated: true,\n+ });\n } else {\n- warn!(path = %path, size_bytes = content.len(), \"Project doc skipped, budget exhausted\");\n+ warn!(path = %path, size_bytes = byte_count, \"Project doc skipped, budget exhausted\");\n }\n }\n }\n }\n \n- let total_bytes: usize = results.iter().map(std::string::String::len).sum();\n+ let total_bytes: usize = results.iter().map(|doc| doc.loaded_bytes).sum();\n info!(files = results.len(), total_bytes, \"Project docs loaded\");\n \n Ok(results)\n@@ -144,7 +170,11 @@ mod tests {\n .await\n .unwrap();\n assert_eq!(docs.len(), 1);\n- assert_eq!(docs[0], \"Agent instructions\");\n+ assert_eq!(docs[0].content, \"Agent instructions\");\n+ assert_eq!(docs[0].path, \"/repo/AGENTS.md\");\n+ assert_eq!(docs[0].byte_count, \"Agent instructions\".len());\n+ assert_eq!(docs[0].loaded_bytes, \"Agent instructions\".len());\n+ assert!(!docs[0].truncated);\n }\n \n #[tokio::test]\n@@ -169,8 +199,8 @@ mod tests {\n .await\n .unwrap();\n assert_eq!(anthropic_docs.len(), 2);\n- assert_eq!(anthropic_docs[0], \"agents\");\n- assert_eq!(anthropic_docs[1], \"claude\");\n+ assert_eq!(anthropic_docs[0].content, \"agents\");\n+ assert_eq!(anthropic_docs[1].content, \"claude\");\n \n let env: Arc = Arc::new(MockSandbox {\n files: files.clone(),\n@@ -186,8 +216,8 @@ mod tests {\n .await\n .unwrap();\n assert_eq!(openai_docs.len(), 2);\n- assert_eq!(openai_docs[0], \"agents\");\n- assert_eq!(openai_docs[1], \"copilot\");\n+ assert_eq!(openai_docs[0].content, \"agents\");\n+ assert_eq!(openai_docs[1].content, \"copilot\");\n \n let env: Arc = Arc::new(MockSandbox {\n files,\n@@ -203,8 +233,8 @@ mod tests {\n .await\n .unwrap();\n assert_eq!(gemini_docs.len(), 2);\n- assert_eq!(gemini_docs[0], \"agents\");\n- assert_eq!(gemini_docs[1], \"gemini\");\n+ assert_eq!(gemini_docs[0].content, \"agents\");\n+ assert_eq!(gemini_docs[1].content, \"gemini\");\n }\n \n #[tokio::test]\n@@ -230,10 +260,18 @@ mod tests {\n .await\n .unwrap();\n assert_eq!(docs.len(), 2);\n- assert_eq!(docs[0], large_content);\n+ assert_eq!(docs[0].content, large_content);\n+ assert!(!docs[0].truncated);\n+ assert_eq!(docs[0].byte_count, docs[0].loaded_bytes);\n // Second doc should be truncated to fit remaining budget\n- assert!(docs[1].ends_with(\"[Project instructions truncated at 32KB]\"));\n- assert!(docs[0].len() + docs[1].len() <= BUDGET_BYTES);\n+ assert!(\n+ docs[1]\n+ .content\n+ .ends_with(\"[Project instructions truncated at 32KB]\")\n+ );\n+ assert!(docs[1].truncated);\n+ assert!(docs[1].byte_count > docs[1].loaded_bytes);\n+ assert!(docs[0].content.len() + docs[1].content.len() <= BUDGET_BYTES);\n }\n \n #[tokio::test]\n@@ -255,7 +293,7 @@ mod tests {\n .await\n .unwrap();\n assert_eq!(docs.len(), 1);\n- assert_eq!(docs[0], \"shared instructions\");\n+ assert_eq!(docs[0].content, \"shared instructions\");\n }\n \n #[tokio::test]\n@@ -277,7 +315,35 @@ mod tests {\n .await\n .unwrap();\n assert_eq!(docs.len(), 1);\n- assert_eq!(docs[0], \"shared instructions\");\n+ assert_eq!(docs[0].content, \"shared instructions\");\n+ }\n+\n+ #[tokio::test]\n+ async fn truncated_file_reports_byte_count_distinct_from_loaded_bytes() {\n+ let mut files = HashMap::new();\n+ // Single file larger than the budget so we hit the truncation branch\n+ // without any preceding consumption.\n+ let large_content = \"x\".repeat(BUDGET_BYTES + 1024);\n+ files.insert(\"/repo/AGENTS.md\".into(), large_content.clone());\n+\n+ let env: Arc = Arc::new(MockSandbox {\n+ files,\n+ ..Default::default()\n+ });\n+ let docs = discover_memory(\n+ env.as_ref(),\n+ \"/repo\",\n+ \"/repo\",\n+ AgentProfileKind::Anthropic,\n+ &CancellationToken::new(),\n+ )\n+ .await\n+ .unwrap();\n+ assert_eq!(docs.len(), 1);\n+ assert!(docs[0].truncated);\n+ assert_eq!(docs[0].byte_count, large_content.len());\n+ assert!(docs[0].loaded_bytes < docs[0].byte_count);\n+ assert!(docs[0].loaded_bytes <= BUDGET_BYTES);\n }\n \n #[tokio::test]\n@@ -301,8 +367,8 @@ mod tests {\n .await\n .unwrap();\n assert_eq!(docs.len(), 3);\n- assert_eq!(docs[0], \"root agents\");\n- assert_eq!(docs[1], \"src agents\");\n- assert_eq!(docs[2], \"app agents\");\n+ assert_eq!(docs[0].content, \"root agents\");\n+ assert_eq!(docs[1].content, \"src agents\");\n+ assert_eq!(docs[2].content, \"app agents\");\n }\n }\ndiff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs\nindex 77d8ca00c..4163e70ed 100644\n--- a/lib/crates/fabro-agent/src/session.rs\n+++ b/lib/crates/fabro-agent/src/session.rs\n@@ -31,7 +31,7 @@ use crate::file_tracker::FileTracker;\n use crate::history::History;\n use crate::loop_detection::detect_loop;\n use crate::mcp_integration;\n-use crate::memory::discover_memory;\n+use crate::memory::{BUDGET_BYTES, MemoryDocument, discover_memory};\n use crate::profiles::EnvContext;\n use crate::sandbox::Sandbox;\n use crate::skills::{\n@@ -39,7 +39,10 @@ use crate::skills::{\n };\n use crate::subagent::{SubAgentCallbackEvent, SubAgentEventCallback, SubAgentManager};\n use crate::tool_execution::execute_tool_calls;\n-use crate::types::{AgentEvent, Message, SessionEvent, SessionState};\n+use crate::types::{\n+ AgentEvent, McpToolSummary, MemoryFileSummary, Message, SessionEvent, SessionState,\n+ SkillActivationSource, SkillSummary,\n+};\n \n /// One queued external control item for a live session.\n #[derive(Debug, Clone)]\n@@ -311,7 +314,7 @@ pub struct Session {\n cancel_token: CancellationToken,\n round_token: Arc>,\n interrupt_reason: Arc>>,\n- memory: Vec,\n+ memory: Vec,\n env_context: EnvContext,\n skills: Vec,\n system_prompt: String,\n@@ -489,6 +492,29 @@ impl Session {\n )\n .await?;\n \n+ let provider_profile_name = self.provider_profile.profile_kind().to_string();\n+\n+ // Emit memory loaded event with file metadata. Contents are deliberately\n+ // omitted so the durable event stream never carries file bytes.\n+ let memory_files: Vec = self\n+ .memory\n+ .iter()\n+ .map(|doc| MemoryFileSummary {\n+ path: doc.path.clone(),\n+ byte_count: doc.byte_count,\n+ loaded_bytes: doc.loaded_bytes,\n+ truncated: doc.truncated,\n+ })\n+ .collect();\n+ let total_loaded_bytes: usize = self.memory.iter().map(|doc| doc.loaded_bytes).sum();\n+ self.event_emitter\n+ .emit(self.id.clone(), AgentEvent::MemoryLoaded {\n+ provider_profile: provider_profile_name.clone(),\n+ files: memory_files,\n+ total_loaded_bytes,\n+ budget_bytes: BUDGET_BYTES,\n+ });\n+\n // Discover skills\n let skill_dirs = if let Some(dirs) = &self.config.skill_dirs {\n dirs.clone()\n@@ -500,6 +526,21 @@ impl Session {\n self.skills = discover_skills(self.sandbox.as_ref(), &skill_dirs, &cancel_token).await?;\n debug!(skill_count = self.skills.len(), \"Skills discovered\");\n \n+ let skill_summaries: Vec = self\n+ .skills\n+ .iter()\n+ .map(|skill| SkillSummary {\n+ name: skill.name.clone(),\n+ description: skill.description.clone(),\n+ })\n+ .collect();\n+ self.event_emitter\n+ .emit(self.id.clone(), AgentEvent::SkillsDiscovered {\n+ provider_profile: provider_profile_name.clone(),\n+ source_dirs: skill_dirs.clone(),\n+ skills: skill_summaries,\n+ });\n+\n // Register use_skill tool when skills are available\n if !self.skills.is_empty() {\n let skills_arc = Arc::new(self.skills.clone());\n@@ -522,10 +563,19 @@ impl Session {\n for (server_name, result) in &results {\n match result {\n Ok(tool_count) => {\n+ let tools = manager\n+ .tool_summaries_for_server(server_name)\n+ .into_iter()\n+ .map(|(name, original_name)| McpToolSummary {\n+ name,\n+ original_name,\n+ })\n+ .collect();\n self.event_emitter\n .emit(self.id.clone(), AgentEvent::McpServerReady {\n server_name: server_name.clone(),\n- tool_count: *tool_count,\n+ tool_count: *tool_count,\n+ tools,\n });\n }\n Err(e) => {\n@@ -555,11 +605,15 @@ impl Session {\n \"Environment context built\"\n );\n \n- // Build system prompt once (static for the session lifetime)\n+ // Build system prompt once (static for the session lifetime). Only\n+ // the loaded memory text is passed to the profile; the document\n+ // metadata is already surfaced via the `agent.memory.loaded` event.\n+ let memory_contents: Vec =\n+ self.memory.iter().map(|doc| doc.content.clone()).collect();\n self.system_prompt = self.provider_profile.build_system_prompt(\n self.sandbox.as_ref(),\n &self.env_context,\n- &self.memory,\n+ &memory_contents,\n self.config.user_instructions.as_deref(),\n &self.skills,\n );\n@@ -1138,8 +1192,9 @@ impl Session {\n };\n if let Some(ref name) = expanded.skill_name {\n self.event_emitter\n- .emit(self.id.clone(), AgentEvent::SkillExpanded {\n+ .emit(self.id.clone(), AgentEvent::SkillActivated {\n skill_name: name.clone(),\n+ source: SkillActivationSource::Slash,\n });\n }\n let expanded_input = expanded.text;\n@@ -1710,9 +1765,10 @@ mod tests {\n \n use super::*;\n use crate::config::{ToolAccess, ToolAccessPolicy, ToolApprovalAdapter, ToolExposureMode};\n+ use crate::skills::{Skill, make_use_skill_tool};\n use crate::subagent::SubAgentStatus;\n use crate::test_support::*;\n- use crate::tool_registry::{RegisteredTool, ToolRegistry};\n+ use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry};\n \n struct NamedToolAccessPolicy {\n decisions: Vec<(&'static str, ToolAccess)>,\n@@ -3649,16 +3705,21 @@ mod tests {\n // Initialize starts the MCP server and registers tools\n session.initialize().await.unwrap();\n \n- // Verify McpServerReady event was emitted\n+ // Verify McpServerReady event was emitted with deterministic tool\n+ // summaries pulled from the connection manager.\n let mut mcp_ready = false;\n while let Ok(event) = rx.try_recv() {\n if let AgentEvent::McpServerReady {\n server_name,\n tool_count,\n+ tools,\n } = &event.event\n {\n assert_eq!(server_name, \"test-echo\");\n assert_eq!(*tool_count, 1);\n+ assert_eq!(tools.len(), 1);\n+ assert_eq!(tools[0].name, \"mcp__test_echo__echo\");\n+ assert_eq!(tools[0].original_name, \"echo\");\n mcp_ready = true;\n }\n }\n@@ -3864,4 +3925,278 @@ mod tests {\n \"ProcessingEnd event should be emitted when returning to Idle\"\n );\n }\n+\n+ async fn build_initialized_session(\n+ sandbox: Arc,\n+ config: SessionOptions,\n+ ) -> Session {\n+ let provider = Arc::new(MockLlmProvider::new(vec![text_response(\"ok\")]));\n+ let client = make_client(provider).await;\n+ let profile = Arc::new(TestProfile::new());\n+ Session::new(client, profile, sandbox, config, None)\n+ }\n+\n+ #[tokio::test]\n+ async fn initialize_emits_memory_loaded_with_file_metadata() {\n+ let mut files = std::collections::HashMap::new();\n+ files.insert(\"/home/test/AGENTS.md\".into(), \"Hello world\".into());\n+ let sandbox = Arc::new(MockSandbox {\n+ files,\n+ ..MockSandbox::linux()\n+ });\n+ let config = SessionOptions {\n+ git_root: Some(\"/home/test\".into()),\n+ skill_dirs: Some(Vec::new()),\n+ ..Default::default()\n+ };\n+ let mut session = build_initialized_session(sandbox, config).await;\n+ let mut rx = session.subscribe();\n+ session.initialize().await.unwrap();\n+\n+ let mut memory_event = None;\n+ while let Ok(envelope) = rx.try_recv() {\n+ if let AgentEvent::MemoryLoaded {\n+ files,\n+ total_loaded_bytes,\n+ budget_bytes,\n+ provider_profile,\n+ } = envelope.event\n+ {\n+ memory_event = Some((files, total_loaded_bytes, budget_bytes, provider_profile));\n+ break;\n+ }\n+ }\n+ let (files, total_loaded_bytes, budget_bytes, provider_profile) =\n+ memory_event.expect(\"MemoryLoaded should be emitted\");\n+ assert_eq!(provider_profile, \"anthropic\");\n+ assert_eq!(budget_bytes, 32768);\n+ assert_eq!(total_loaded_bytes, \"Hello world\".len());\n+ assert_eq!(files.len(), 1);\n+ assert_eq!(files[0].path, \"/home/test/AGENTS.md\");\n+ assert_eq!(files[0].byte_count, \"Hello world\".len());\n+ assert_eq!(files[0].loaded_bytes, \"Hello world\".len());\n+ assert!(!files[0].truncated);\n+ }\n+\n+ #[tokio::test]\n+ async fn initialize_emits_memory_loaded_event_with_empty_files_when_no_memory() {\n+ let sandbox = Arc::new(MockSandbox::linux());\n+ let config = SessionOptions {\n+ git_root: Some(\"/home/test\".into()),\n+ skill_dirs: Some(Vec::new()),\n+ ..Default::default()\n+ };\n+ let mut session = build_initialized_session(sandbox, config).await;\n+ let mut rx = session.subscribe();\n+ session.initialize().await.unwrap();\n+\n+ let mut saw_memory = false;\n+ while let Ok(envelope) = rx.try_recv() {\n+ if let AgentEvent::MemoryLoaded { files, .. } = envelope.event {\n+ assert!(files.is_empty());\n+ saw_memory = true;\n+ break;\n+ }\n+ }\n+ assert!(\n+ saw_memory,\n+ \"MemoryLoaded must be emitted even when no memory files are loaded\"\n+ );\n+ }\n+\n+ #[tokio::test]\n+ async fn initialize_emits_skills_discovered_with_summaries() {\n+ let mut files = std::collections::HashMap::new();\n+ files.insert(\n+ \"/skills/commit/SKILL.md\".into(),\n+ \"---\\nname: commit\\ndescription: Make a commit\\n---\\nDo commit\".into(),\n+ );\n+ let sandbox = Arc::new(MockSandbox {\n+ files,\n+ glob_results: vec![\"/skills/commit/SKILL.md\".into()],\n+ ..MockSandbox::linux()\n+ });\n+ let config = SessionOptions {\n+ git_root: Some(\"/home/test\".into()),\n+ skill_dirs: Some(vec![\"/skills\".into()]),\n+ ..Default::default()\n+ };\n+ let mut session = build_initialized_session(sandbox, config).await;\n+ let mut rx = session.subscribe();\n+ session.initialize().await.unwrap();\n+\n+ let mut got = None;\n+ while let Ok(envelope) = rx.try_recv() {\n+ if let AgentEvent::SkillsDiscovered {\n+ provider_profile,\n+ source_dirs,\n+ skills,\n+ } = envelope.event\n+ {\n+ got = Some((provider_profile, source_dirs, skills));\n+ break;\n+ }\n+ }\n+ let (provider_profile, source_dirs, skills) =\n+ got.expect(\"SkillsDiscovered must be emitted\");\n+ assert_eq!(provider_profile, \"anthropic\");\n+ assert_eq!(source_dirs, vec![\"/skills\".to_string()]);\n+ assert_eq!(skills.len(), 1);\n+ assert_eq!(skills[0].name, \"commit\");\n+ assert_eq!(skills[0].description, \"Make a commit\");\n+ }\n+\n+ #[tokio::test]\n+ async fn initialize_emits_skills_discovered_event_when_no_skills() {\n+ let sandbox = Arc::new(MockSandbox::linux());\n+ let config = SessionOptions {\n+ git_root: Some(\"/home/test\".into()),\n+ skill_dirs: Some(Vec::new()),\n+ ..Default::default()\n+ };\n+ let mut session = build_initialized_session(sandbox, config).await;\n+ let mut rx = session.subscribe();\n+ session.initialize().await.unwrap();\n+\n+ let mut saw_skills = false;\n+ while let Ok(envelope) = rx.try_recv() {\n+ if let AgentEvent::SkillsDiscovered { skills, .. } = envelope.event {\n+ assert!(skills.is_empty());\n+ saw_skills = true;\n+ break;\n+ }\n+ }\n+ assert!(\n+ saw_skills,\n+ \"SkillsDiscovered must be emitted even when no skills are present\"\n+ );\n+ }\n+\n+ #[tokio::test]\n+ async fn slash_skill_expansion_emits_skill_activated_with_slash_source() {\n+ let mut files = std::collections::HashMap::new();\n+ files.insert(\n+ \"/skills/commit/SKILL.md\".into(),\n+ \"---\\nname: commit\\ndescription: Make a commit\\n---\\nRun commit. {{user_input}}\".into(),\n+ );\n+ let sandbox = Arc::new(MockSandbox {\n+ files,\n+ glob_results: vec![\"/skills/commit/SKILL.md\".into()],\n+ ..MockSandbox::linux()\n+ });\n+ let config = SessionOptions {\n+ git_root: Some(\"/home/test\".into()),\n+ skill_dirs: Some(vec![\"/skills\".into()]),\n+ ..Default::default()\n+ };\n+ let provider = Arc::new(MockLlmProvider::new(vec![text_response(\"ok\")]));\n+ let client = make_client(provider).await;\n+ let profile = Arc::new(TestProfile::new());\n+ let mut session = Session::new(client, profile, sandbox, config, None);\n+ session.initialize().await.unwrap();\n+\n+ let mut rx = session.subscribe();\n+ session.process_input(\"/commit fix things\").await.unwrap();\n+\n+ let mut activations: Vec<(String, SkillActivationSource)> = Vec::new();\n+ while let Ok(envelope) = rx.try_recv() {\n+ if let AgentEvent::SkillActivated { skill_name, source } = envelope.event {\n+ activations.push((skill_name, source));\n+ }\n+ }\n+ assert!(\n+ activations\n+ .iter()\n+ .any(|(name, source)| name == \"commit\" && *source == SkillActivationSource::Slash),\n+ \"expected slash skill activation, got {activations:?}\"\n+ );\n+ }\n+\n+ #[tokio::test]\n+ async fn use_skill_tool_success_emits_skill_activated_with_tool_source() {\n+ let mut files = std::collections::HashMap::new();\n+ files.insert(\n+ \"/skills/commit/SKILL.md\".into(),\n+ \"---\\nname: commit\\ndescription: Make a commit\\n---\\nRun commit.\".into(),\n+ );\n+ let sandbox = Arc::new(MockSandbox {\n+ files,\n+ glob_results: vec![\"/skills/commit/SKILL.md\".into()],\n+ ..MockSandbox::linux()\n+ });\n+ let config = SessionOptions {\n+ git_root: Some(\"/home/test\".into()),\n+ skill_dirs: Some(vec![\"/skills\".into()]),\n+ enable_loop_detection: false,\n+ ..Default::default()\n+ };\n+ let responses = vec![\n+ tool_call_response(\n+ \"use_skill\",\n+ \"call_1\",\n+ serde_json::json!({\"skill_name\": \"commit\"}),\n+ ),\n+ text_response(\"done\"),\n+ ];\n+ let provider = Arc::new(MockLlmProvider::new(responses));\n+ let client = make_client(provider).await;\n+ let profile = Arc::new(TestProfile::new());\n+ let mut session = Session::new(client, profile, sandbox, config, None);\n+ session.initialize().await.unwrap();\n+\n+ let mut rx = session.subscribe();\n+ session.process_input(\"please commit\").await.unwrap();\n+\n+ let mut tool_activations = 0;\n+ while let Ok(envelope) = rx.try_recv() {\n+ if let AgentEvent::SkillActivated { source, skill_name } = envelope.event {\n+ if source == SkillActivationSource::Tool && skill_name == \"commit\" {\n+ tool_activations += 1;\n+ }\n+ }\n+ }\n+ assert_eq!(\n+ tool_activations, 1,\n+ \"expected exactly one tool-sourced skill activation\"\n+ );\n+ }\n+\n+ #[tokio::test]\n+ async fn use_skill_tool_failed_lookup_does_not_emit_activation() {\n+ let sandbox = Arc::new(MockSandbox::linux());\n+ let config = SessionOptions {\n+ git_root: Some(\"/home/test\".into()),\n+ skill_dirs: Some(Vec::new()),\n+ ..Default::default()\n+ };\n+ let provider = Arc::new(MockLlmProvider::new(vec![text_response(\"ok\")]));\n+ let client = make_client(provider).await;\n+ let profile = Arc::new(TestProfile::new());\n+ let mut session = Session::new(client, profile, sandbox, config, None);\n+ session.initialize().await.unwrap();\n+\n+ // Build a use_skill tool with an empty skill list, then invoke it\n+ // directly with a missing name. We must NOT see a SkillActivated event.\n+ let skills_arc = Arc::new(Vec::::new());\n+ let tool = make_use_skill_tool(skills_arc);\n+ let mut rx = session.subscribe();\n+ let env: Arc = Arc::new(MockSandbox::default());\n+ let ctx = ToolContext {\n+ env,\n+ cancel: CancellationToken::new(),\n+ tool_env_provider: None,\n+ session_id: Some(session.id().to_string()),\n+ root_session_id: Some(session.id().to_string()),\n+ tool_call_id: None,\n+ agent_event_emitter: None,\n+ };\n+ let result = (tool.executor)(serde_json::json!({\"skill_name\": \"nope\"}), ctx).await;\n+ assert!(result.is_err());\n+\n+ while let Ok(envelope) = rx.try_recv() {\n+ if matches!(envelope.event, AgentEvent::SkillActivated { .. }) {\n+ panic!(\"failed use_skill should not emit SkillActivated\");\n+ }\n+ }\n+ }\n }\ndiff --git a/lib/crates/fabro-agent/src/skills.rs b/lib/crates/fabro-agent/src/skills.rs\nindex 4bb2ff8f4..f7b4e3eaf 100644\n--- a/lib/crates/fabro-agent/src/skills.rs\n+++ b/lib/crates/fabro-agent/src/skills.rs\n@@ -7,6 +7,7 @@ use crate::error::{Error, InterruptReason};\n use crate::sandbox::Sandbox;\n use crate::tool_registry::RegisteredTool;\n use crate::tools::required_str;\n+use crate::types::{AgentEvent, SkillActivationSource};\n \n #[derive(Debug, Clone)]\n pub struct Skill {\n@@ -176,7 +177,7 @@ pub fn make_use_skill_tool(skills: Arc>) -> RegisteredTool {\n \"required\": [\"skill_name\"]\n }),\n },\n- executor: Arc::new(move |args, _ctx| {\n+ executor: Arc::new(move |args, ctx| {\n let skills = skills.clone();\n Box::pin(async move {\n let name = required_str(&args, \"skill_name\")?;\n@@ -184,6 +185,10 @@ pub fn make_use_skill_tool(skills: Arc>) -> RegisteredTool {\n .iter()\n .find(|s| s.name == name)\n .ok_or_else(|| format!(\"Unknown skill: {name}\"))?;\n+ ctx.emit_agent_event(AgentEvent::SkillActivated {\n+ skill_name: name.to_string(),\n+ source: SkillActivationSource::Tool,\n+ });\n Ok(skill.template.clone())\n })\n }),\ndiff --git a/lib/crates/fabro-agent/src/types.rs b/lib/crates/fabro-agent/src/types.rs\nindex 1f11584d1..95833a2d8 100644\n--- a/lib/crates/fabro-agent/src/types.rs\n+++ b/lib/crates/fabro-agent/src/types.rs\n@@ -194,6 +194,33 @@ pub enum SessionState {\n Closed,\n }\n \n+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+pub struct MemoryFileSummary {\n+ pub path: String,\n+ pub byte_count: usize,\n+ pub loaded_bytes: usize,\n+ pub truncated: bool,\n+}\n+\n+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+pub struct SkillSummary {\n+ pub name: String,\n+ pub description: String,\n+}\n+\n+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+pub struct McpToolSummary {\n+ pub name: String,\n+ pub original_name: String,\n+}\n+\n+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n+#[serde(rename_all = \"snake_case\")]\n+pub enum SkillActivationSource {\n+ Slash,\n+ Tool,\n+}\n+\n #[derive(Debug, Clone, Serialize, Deserialize)]\n pub enum AgentEvent {\n SessionStarted {\n@@ -303,11 +330,27 @@ pub enum AgentEvent {\n McpServerReady {\n server_name: String,\n tool_count: usize,\n+ tools: Vec,\n },\n McpServerFailed {\n server_name: String,\n error: String,\n },\n+ MemoryLoaded {\n+ provider_profile: String,\n+ files: Vec,\n+ total_loaded_bytes: usize,\n+ budget_bytes: usize,\n+ },\n+ SkillsDiscovered {\n+ provider_profile: String,\n+ source_dirs: Vec,\n+ skills: Vec,\n+ },\n+ SkillActivated {\n+ skill_name: String,\n+ source: SkillActivationSource,\n+ },\n /// New todo / task was created. Carries the full row so the projection\n /// can be reconstructed from `todo.created` alone.\n TodoCreated(fabro_types::TodoCreatedProps),\n@@ -504,14 +547,52 @@ impl AgentEvent {\n Self::McpServerReady {\n server_name,\n tool_count,\n+ tools,\n } => {\n info!(\n session_id,\n server = server_name.as_str(),\n tool_count,\n+ summary_count = tools.len(),\n \"MCP server ready\"\n );\n }\n+ Self::MemoryLoaded {\n+ provider_profile,\n+ files,\n+ total_loaded_bytes,\n+ budget_bytes,\n+ } => {\n+ info!(\n+ session_id,\n+ provider_profile = provider_profile.as_str(),\n+ file_count = files.len(),\n+ total_loaded_bytes,\n+ budget_bytes,\n+ \"Agent memory loaded\"\n+ );\n+ }\n+ Self::SkillsDiscovered {\n+ provider_profile,\n+ source_dirs,\n+ skills,\n+ } => {\n+ info!(\n+ session_id,\n+ provider_profile = provider_profile.as_str(),\n+ skill_count = skills.len(),\n+ source_dir_count = source_dirs.len(),\n+ \"Agent skills discovered\"\n+ );\n+ }\n+ Self::SkillActivated { skill_name, source } => {\n+ debug!(\n+ session_id,\n+ skill = skill_name.as_str(),\n+ source = ?source,\n+ \"Agent skill activated\"\n+ );\n+ }\n Self::McpServerFailed { server_name, error } => {\n error!(\n session_id,\n@@ -752,6 +833,7 @@ mod tests {\n let event = AgentEvent::McpServerReady {\n server_name: \"filesystem\".into(),\n tool_count: 3,\n+ tools: Vec::new(),\n };\n assert!(matches!(event, AgentEvent::McpServerReady {\n tool_count: 3,\n@@ -776,6 +858,7 @@ mod tests {\n AgentEvent::McpServerReady {\n server_name: \"fs\".into(),\n tool_count: 5,\n+ tools: Vec::new(),\n },\n AgentEvent::McpServerFailed {\n server_name: \"bad\".into(),\ndiff --git a/lib/crates/fabro-mcp/src/connection_manager.rs b/lib/crates/fabro-mcp/src/connection_manager.rs\nindex 59e12f6cc..3eff94b30 100644\n--- a/lib/crates/fabro-mcp/src/connection_manager.rs\n+++ b/lib/crates/fabro-mcp/src/connection_manager.rs\n@@ -151,6 +151,22 @@ impl McpConnectionManager {\n &self.tools\n }\n \n+ /// Names-only tool summaries for the given server, sorted by qualified\n+ /// name. Returns `(qualified_name, original_tool_name)` pairs. Useful\n+ /// for emitting deterministic `agent.mcp.ready` payloads without\n+ /// leaking descriptions or input schemas.\n+ #[must_use]\n+ pub fn tool_summaries_for_server(&self, server_name: &str) -> Vec<(String, String)> {\n+ let mut summaries: Vec<(String, String)> = self\n+ .tools\n+ .iter()\n+ .filter(|(_, info)| info.server_name == server_name)\n+ .map(|(qualified, info)| (qualified.clone(), info.original_tool_name.clone()))\n+ .collect();\n+ summaries.sort_by(|a, b| a.0.cmp(&b.0));\n+ summaries\n+ }\n+\n /// Call a tool by its qualified name.\n pub async fn call_tool(\n &self,\n@@ -303,4 +319,45 @@ mod tests {\n let mgr = McpConnectionManager::new();\n assert!(mgr.all_tools().is_empty());\n }\n+\n+ #[test]\n+ fn tool_summaries_for_server_filters_and_sorts_by_qualified_name() {\n+ let mut mgr = McpConnectionManager::new();\n+ mgr.tools\n+ .insert(qualified_tool_name(\"github\", \"list_issues\"), ToolInfo {\n+ server_name: \"github\".to_string(),\n+ original_tool_name: \"list_issues\".to_string(),\n+ description: \"list issues\".to_string(),\n+ input_schema: serde_json::json!({}),\n+ });\n+ mgr.tools\n+ .insert(qualified_tool_name(\"github\", \"create_issue\"), ToolInfo {\n+ server_name: \"github\".to_string(),\n+ original_tool_name: \"create_issue\".to_string(),\n+ description: \"create issue\".to_string(),\n+ input_schema: serde_json::json!({}),\n+ });\n+ mgr.tools\n+ .insert(qualified_tool_name(\"other\", \"noop\"), ToolInfo {\n+ server_name: \"other\".to_string(),\n+ original_tool_name: \"noop\".to_string(),\n+ description: \"noop\".to_string(),\n+ input_schema: serde_json::json!({}),\n+ });\n+\n+ let summaries = mgr.tool_summaries_for_server(\"github\");\n+ assert_eq!(summaries.len(), 2);\n+ assert_eq!(summaries[0].0, \"mcp__github__create_issue\");\n+ assert_eq!(summaries[0].1, \"create_issue\");\n+ assert_eq!(summaries[1].0, \"mcp__github__list_issues\");\n+ assert_eq!(summaries[1].1, \"list_issues\");\n+\n+ let other = mgr.tool_summaries_for_server(\"other\");\n+ assert_eq!(other.len(), 1);\n+ assert_eq!(other[0].0, \"mcp__other__noop\");\n+ assert_eq!(other[0].1, \"noop\");\n+\n+ let none = mgr.tool_summaries_for_server(\"missing\");\n+ assert!(none.is_empty());\n+ }\n }\ndiff --git a/lib/crates/fabro-types/src/run_event/agent.rs b/lib/crates/fabro-types/src/run_event/agent.rs\nindex eb4fe13c2..56106dc60 100644\n--- a/lib/crates/fabro-types/src/run_event/agent.rs\n+++ b/lib/crates/fabro-types/src/run_event/agent.rs\n@@ -215,12 +215,65 @@ pub struct AgentSubClosedProps {\n pub struct AgentMcpReadyProps {\n pub server_name: String,\n pub tool_count: usize,\n+ #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n+ pub tools: Vec,\n pub visit: u32,\n }\n \n+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+pub struct AgentMcpToolSummary {\n+ pub name: String,\n+ pub original_name: String,\n+}\n+\n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n pub struct AgentMcpFailedProps {\n pub server_name: String,\n pub error: String,\n pub visit: u32,\n }\n+\n+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+pub struct AgentMemoryLoadedProps {\n+ pub provider_profile: String,\n+ pub files: Vec,\n+ pub total_loaded_bytes: usize,\n+ pub budget_bytes: usize,\n+ pub visit: u32,\n+}\n+\n+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+pub struct AgentMemoryFileProps {\n+ pub path: String,\n+ pub byte_count: usize,\n+ pub loaded_bytes: usize,\n+ pub truncated: bool,\n+}\n+\n+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+pub struct AgentSkillsDiscoveredProps {\n+ pub provider_profile: String,\n+ pub source_dirs: Vec,\n+ pub skills: Vec,\n+ pub visit: u32,\n+}\n+\n+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+pub struct AgentSkillSummary {\n+ pub name: String,\n+ pub description: String,\n+}\n+\n+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n+#[serde(rename_all = \"snake_case\")]\n+pub enum AgentSkillActivationSource {\n+ Slash,\n+ Tool,\n+}\n+\n+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+pub struct AgentSkillActivatedProps {\n+ pub skill_name: String,\n+ pub source: AgentSkillActivationSource,\n+ pub visit: u32,\n+}\ndiff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs\nindex 4cfd89b00..564dea99a 100644\n--- a/lib/crates/fabro-types/src/run_event/mod.rs\n+++ b/lib/crates/fabro-types/src/run_event/mod.rs\n@@ -242,6 +242,12 @@ pub enum EventBody {\n AgentMcpReady(AgentMcpReadyProps),\n #[serde(rename = \"agent.mcp.failed\")]\n AgentMcpFailed(AgentMcpFailedProps),\n+ #[serde(rename = \"agent.memory.loaded\")]\n+ AgentMemoryLoaded(AgentMemoryLoadedProps),\n+ #[serde(rename = \"agent.skills.discovered\")]\n+ AgentSkillsDiscovered(AgentSkillsDiscoveredProps),\n+ #[serde(rename = \"agent.skill.activated\")]\n+ AgentSkillActivated(AgentSkillActivatedProps),\n #[serde(rename = \"todo.created\")]\n TodoCreated(TodoCreatedProps),\n #[serde(rename = \"todo.updated\")]\n@@ -506,6 +512,9 @@ impl EventBody {\n Self::AgentSubClosed(_) => \"agent.sub.closed\",\n Self::AgentMcpReady(_) => \"agent.mcp.ready\",\n Self::AgentMcpFailed(_) => \"agent.mcp.failed\",\n+ Self::AgentMemoryLoaded(_) => \"agent.memory.loaded\",\n+ Self::AgentSkillsDiscovered(_) => \"agent.skills.discovered\",\n+ Self::AgentSkillActivated(_) => \"agent.skill.activated\",\n Self::TodoCreated(_) => \"todo.created\",\n Self::TodoUpdated(_) => \"todo.updated\",\n Self::TodoDeleted(_) => \"todo.deleted\",\n@@ -680,6 +689,9 @@ fn is_known_event_name(event: &str) -> bool {\n | \"agent.sub.closed\"\n | \"agent.mcp.ready\"\n | \"agent.mcp.failed\"\n+ | \"agent.memory.loaded\"\n+ | \"agent.skills.discovered\"\n+ | \"agent.skill.activated\"\n | \"todo.created\"\n | \"todo.updated\"\n | \"todo.deleted\"\n@@ -1983,4 +1995,138 @@ mod tests {\n other => panic!(\"expected TodoDeleted, got {other:?}\"),\n }\n }\n+\n+ #[test]\n+ fn agent_memory_loaded_serializes_with_canonical_name() {\n+ let body = EventBody::AgentMemoryLoaded(AgentMemoryLoadedProps {\n+ provider_profile: \"anthropic\".to_string(),\n+ files: vec![AgentMemoryFileProps {\n+ path: \"/repo/AGENTS.md\".to_string(),\n+ byte_count: 100,\n+ loaded_bytes: 100,\n+ truncated: false,\n+ }],\n+ total_loaded_bytes: 100,\n+ budget_bytes: 32768,\n+ visit: 1,\n+ });\n+ let value = serde_json::to_value(&body).unwrap();\n+ assert_eq!(value[\"event\"], \"agent.memory.loaded\");\n+ assert_eq!(value[\"properties\"][\"provider_profile\"], \"anthropic\");\n+ assert_eq!(value[\"properties\"][\"files\"][0][\"path\"], \"/repo/AGENTS.md\");\n+ assert_eq!(value[\"properties\"][\"budget_bytes\"], 32768);\n+ assert!(\n+ value[\"properties\"]\n+ .as_object()\n+ .unwrap()\n+ .get(\"content\")\n+ .is_none(),\n+ \"memory event must not include file content\"\n+ );\n+ let _ = serde_json::from_value::(value).unwrap();\n+ }\n+\n+ #[test]\n+ fn agent_skills_discovered_serializes_with_canonical_name() {\n+ let body = EventBody::AgentSkillsDiscovered(AgentSkillsDiscoveredProps {\n+ provider_profile: \"openai\".to_string(),\n+ source_dirs: vec![\"/repo/.fabro/skills\".to_string()],\n+ skills: vec![AgentSkillSummary {\n+ name: \"commit\".to_string(),\n+ description: \"Create a commit\".to_string(),\n+ }],\n+ visit: 2,\n+ });\n+ let value = serde_json::to_value(&body).unwrap();\n+ assert_eq!(value[\"event\"], \"agent.skills.discovered\");\n+ assert_eq!(value[\"properties\"][\"skills\"][0][\"name\"], \"commit\");\n+ let _: EventBody = serde_json::from_value(value).unwrap();\n+ }\n+\n+ #[test]\n+ fn agent_skill_activated_serializes_source_variants() {\n+ let slash = EventBody::AgentSkillActivated(AgentSkillActivatedProps {\n+ skill_name: \"commit\".to_string(),\n+ source: AgentSkillActivationSource::Slash,\n+ visit: 3,\n+ });\n+ let value = serde_json::to_value(&slash).unwrap();\n+ assert_eq!(value[\"event\"], \"agent.skill.activated\");\n+ assert_eq!(value[\"properties\"][\"source\"], \"slash\");\n+\n+ let tool = EventBody::AgentSkillActivated(AgentSkillActivatedProps {\n+ skill_name: \"commit\".to_string(),\n+ source: AgentSkillActivationSource::Tool,\n+ visit: 4,\n+ });\n+ let value = serde_json::to_value(&tool).unwrap();\n+ assert_eq!(value[\"properties\"][\"source\"], \"tool\");\n+ }\n+\n+ #[test]\n+ fn agent_mcp_ready_deserializes_legacy_payload_without_tools() {\n+ let value = json!({\n+ \"id\": \"evt_mcp_ready\",\n+ \"ts\": \"2026-05-22T12:00:00.000Z\",\n+ \"run_id\": fixtures::RUN_1,\n+ \"event\": \"agent.mcp.ready\",\n+ \"properties\": {\n+ \"server_name\": \"github\",\n+ \"tool_count\": 2,\n+ \"visit\": 1\n+ }\n+ });\n+ let parsed = RunEvent::from_value(value).unwrap();\n+ match parsed.body {\n+ EventBody::AgentMcpReady(props) => {\n+ assert_eq!(props.server_name, \"github\");\n+ assert_eq!(props.tool_count, 2);\n+ assert!(props.tools.is_empty());\n+ assert_eq!(props.visit, 1);\n+ }\n+ other => panic!(\"expected AgentMcpReady body, got {other:?}\"),\n+ }\n+ }\n+\n+ #[test]\n+ fn agent_mcp_ready_serializes_with_tool_summaries() {\n+ let body = EventBody::AgentMcpReady(AgentMcpReadyProps {\n+ server_name: \"github\".to_string(),\n+ tool_count: 1,\n+ tools: vec![AgentMcpToolSummary {\n+ name: \"mcp__github__create_issue\".to_string(),\n+ original_name: \"create_issue\".to_string(),\n+ }],\n+ visit: 1,\n+ });\n+ let value = serde_json::to_value(&body).unwrap();\n+ assert_eq!(value[\"event\"], \"agent.mcp.ready\");\n+ assert_eq!(\n+ value[\"properties\"][\"tools\"][0][\"name\"],\n+ \"mcp__github__create_issue\"\n+ );\n+ assert_eq!(\n+ value[\"properties\"][\"tools\"][0][\"original_name\"],\n+ \"create_issue\"\n+ );\n+ }\n+\n+ #[test]\n+ fn agent_mcp_ready_omits_tools_when_empty() {\n+ let body = EventBody::AgentMcpReady(AgentMcpReadyProps {\n+ server_name: \"github\".to_string(),\n+ tool_count: 0,\n+ tools: Vec::new(),\n+ visit: 1,\n+ });\n+ let value = serde_json::to_value(&body).unwrap();\n+ assert!(\n+ value[\"properties\"]\n+ .as_object()\n+ .unwrap()\n+ .get(\"tools\")\n+ .is_none(),\n+ \"empty tools should be omitted for legacy parity\"\n+ );\n+ }\n }\ndiff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs\nindex 83eefb759..c1e008159 100644\n--- a/lib/crates/fabro-workflow/src/event/convert.rs\n+++ b/lib/crates/fabro-workflow/src/event/convert.rs\n@@ -722,9 +722,17 @@ fn event_body_from_event(event: &Event) -> EventBody {\n AgentEvent::McpServerReady {\n server_name,\n tool_count,\n+ tools,\n } => EventBody::AgentMcpReady(fabro_types::AgentMcpReadyProps {\n server_name: server_name.clone(),\n tool_count: *tool_count,\n+ tools: tools\n+ .iter()\n+ .map(|tool| fabro_types::AgentMcpToolSummary {\n+ name: tool.name.clone(),\n+ original_name: tool.original_name.clone(),\n+ })\n+ .collect(),\n visit: *visit,\n }),\n AgentEvent::McpServerFailed { server_name, error } => {\n@@ -734,6 +742,56 @@ fn event_body_from_event(event: &Event) -> EventBody {\n visit: *visit,\n })\n }\n+ AgentEvent::MemoryLoaded {\n+ provider_profile,\n+ files,\n+ total_loaded_bytes,\n+ budget_bytes,\n+ } => EventBody::AgentMemoryLoaded(fabro_types::AgentMemoryLoadedProps {\n+ provider_profile: provider_profile.clone(),\n+ files: files\n+ .iter()\n+ .map(|file| fabro_types::AgentMemoryFileProps {\n+ path: file.path.clone(),\n+ byte_count: file.byte_count,\n+ loaded_bytes: file.loaded_bytes,\n+ truncated: file.truncated,\n+ })\n+ .collect(),\n+ total_loaded_bytes: *total_loaded_bytes,\n+ budget_bytes: *budget_bytes,\n+ visit: *visit,\n+ }),\n+ AgentEvent::SkillsDiscovered {\n+ provider_profile,\n+ source_dirs,\n+ skills,\n+ } => EventBody::AgentSkillsDiscovered(fabro_types::AgentSkillsDiscoveredProps {\n+ provider_profile: provider_profile.clone(),\n+ source_dirs: source_dirs.clone(),\n+ skills: skills\n+ .iter()\n+ .map(|skill| fabro_types::AgentSkillSummary {\n+ name: skill.name.clone(),\n+ description: skill.description.clone(),\n+ })\n+ .collect(),\n+ visit: *visit,\n+ }),\n+ AgentEvent::SkillActivated { skill_name, source } => {\n+ EventBody::AgentSkillActivated(fabro_types::AgentSkillActivatedProps {\n+ skill_name: skill_name.clone(),\n+ source: match source {\n+ fabro_agent::SkillActivationSource::Slash => {\n+ fabro_types::AgentSkillActivationSource::Slash\n+ }\n+ fabro_agent::SkillActivationSource::Tool => {\n+ fabro_types::AgentSkillActivationSource::Tool\n+ }\n+ },\n+ visit: *visit,\n+ })\n+ }\n AgentEvent::TodoCreated(props) => EventBody::TodoCreated(props.clone()),\n AgentEvent::TodoUpdated(props) => EventBody::TodoUpdated(props.clone()),\n AgentEvent::TodoDeleted(props) => EventBody::TodoDeleted(props.clone()),\n@@ -2262,4 +2320,181 @@ mod tests {\n let actor = stored.actor.as_ref().expect(\"actor set\");\n assert_eq!(actor, &user_principal(\"alice\"));\n }\n+\n+ #[test]\n+ fn agent_memory_loaded_maps_to_typed_event_body() {\n+ let stored = to_run_event(&fixtures::RUN_1, &Event::Agent {\n+ stage: \"code\".to_string(),\n+ visit: 3,\n+ event: AgentEvent::MemoryLoaded {\n+ provider_profile: \"anthropic\".to_string(),\n+ files: vec![fabro_agent::MemoryFileSummary {\n+ path: \"/repo/AGENTS.md\".to_string(),\n+ byte_count: 200,\n+ loaded_bytes: 200,\n+ truncated: false,\n+ }],\n+ total_loaded_bytes: 200,\n+ budget_bytes: 32768,\n+ },\n+ session_id: Some(\"ses_1\".to_string()),\n+ parent_session_id: None,\n+ tool_call_id: None,\n+ });\n+ assert_eq!(stored.event_name(), \"agent.memory.loaded\");\n+ match stored.body {\n+ EventBody::AgentMemoryLoaded(props) => {\n+ assert_eq!(props.visit, 3);\n+ assert_eq!(props.provider_profile, \"anthropic\");\n+ assert_eq!(props.budget_bytes, 32768);\n+ assert_eq!(props.total_loaded_bytes, 200);\n+ assert_eq!(props.files.len(), 1);\n+ assert_eq!(props.files[0].path, \"/repo/AGENTS.md\");\n+ assert_eq!(props.files[0].byte_count, 200);\n+ assert_eq!(props.files[0].loaded_bytes, 200);\n+ assert!(!props.files[0].truncated);\n+ }\n+ other => panic!(\"expected AgentMemoryLoaded body, got {other:?}\"),\n+ }\n+ }\n+\n+ #[test]\n+ fn agent_memory_loaded_payload_excludes_file_contents() {\n+ let stored = to_run_event(&fixtures::RUN_1, &Event::Agent {\n+ stage: \"code\".to_string(),\n+ visit: 1,\n+ event: AgentEvent::MemoryLoaded {\n+ provider_profile: \"openai\".to_string(),\n+ files: vec![fabro_agent::MemoryFileSummary {\n+ path: \"/repo/AGENTS.md\".to_string(),\n+ byte_count: 100,\n+ loaded_bytes: 100,\n+ truncated: false,\n+ }],\n+ total_loaded_bytes: 100,\n+ budget_bytes: 32768,\n+ },\n+ session_id: None,\n+ parent_session_id: None,\n+ tool_call_id: None,\n+ });\n+ let serialized = serde_json::to_string(&stored.body).unwrap();\n+ assert!(\n+ !serialized.contains(\"content\"),\n+ \"memory event payload must not contain file content\"\n+ );\n+ }\n+\n+ #[test]\n+ fn agent_skills_discovered_maps_to_typed_event_body() {\n+ let stored = to_run_event(&fixtures::RUN_1, &Event::Agent {\n+ stage: \"code\".to_string(),\n+ visit: 2,\n+ event: AgentEvent::SkillsDiscovered {\n+ provider_profile: \"anthropic\".to_string(),\n+ source_dirs: vec![\"/repo/.fabro/skills\".to_string()],\n+ skills: vec![fabro_agent::SkillSummary {\n+ name: \"commit\".to_string(),\n+ description: \"Make a commit\".to_string(),\n+ }],\n+ },\n+ session_id: Some(\"ses_1\".to_string()),\n+ parent_session_id: None,\n+ tool_call_id: None,\n+ });\n+ assert_eq!(stored.event_name(), \"agent.skills.discovered\");\n+ match stored.body {\n+ EventBody::AgentSkillsDiscovered(props) => {\n+ assert_eq!(props.visit, 2);\n+ assert_eq!(props.provider_profile, \"anthropic\");\n+ assert_eq!(props.source_dirs, vec![\"/repo/.fabro/skills\".to_string()]);\n+ assert_eq!(props.skills.len(), 1);\n+ assert_eq!(props.skills[0].name, \"commit\");\n+ assert_eq!(props.skills[0].description, \"Make a commit\");\n+ }\n+ other => panic!(\"expected AgentSkillsDiscovered body, got {other:?}\"),\n+ }\n+ }\n+\n+ #[test]\n+ fn agent_skill_activated_maps_slash_and_tool_sources() {\n+ let slash = to_run_event(&fixtures::RUN_1, &Event::Agent {\n+ stage: \"code\".to_string(),\n+ visit: 1,\n+ event: AgentEvent::SkillActivated {\n+ skill_name: \"commit\".to_string(),\n+ source: fabro_agent::SkillActivationSource::Slash,\n+ },\n+ session_id: Some(\"ses_1\".to_string()),\n+ parent_session_id: None,\n+ tool_call_id: None,\n+ });\n+ assert_eq!(slash.event_name(), \"agent.skill.activated\");\n+ match slash.body {\n+ EventBody::AgentSkillActivated(props) => {\n+ assert_eq!(props.visit, 1);\n+ assert_eq!(props.skill_name, \"commit\");\n+ assert_eq!(props.source, fabro_types::AgentSkillActivationSource::Slash);\n+ }\n+ other => panic!(\"expected AgentSkillActivated body, got {other:?}\"),\n+ }\n+\n+ let tool = to_run_event(&fixtures::RUN_1, &Event::Agent {\n+ stage: \"code\".to_string(),\n+ visit: 4,\n+ event: AgentEvent::SkillActivated {\n+ skill_name: \"review\".to_string(),\n+ source: fabro_agent::SkillActivationSource::Tool,\n+ },\n+ session_id: None,\n+ parent_session_id: None,\n+ tool_call_id: None,\n+ });\n+ match tool.body {\n+ EventBody::AgentSkillActivated(props) => {\n+ assert_eq!(props.visit, 4);\n+ assert_eq!(props.skill_name, \"review\");\n+ assert_eq!(props.source, fabro_types::AgentSkillActivationSource::Tool);\n+ }\n+ other => panic!(\"expected AgentSkillActivated body, got {other:?}\"),\n+ }\n+ }\n+\n+ #[test]\n+ fn agent_mcp_ready_carries_tool_summaries_and_visit() {\n+ let stored = to_run_event(&fixtures::RUN_1, &Event::Agent {\n+ stage: \"code\".to_string(),\n+ visit: 5,\n+ event: AgentEvent::McpServerReady {\n+ server_name: \"github\".to_string(),\n+ tool_count: 2,\n+ tools: vec![\n+ fabro_agent::McpToolSummary {\n+ name: \"mcp__github__create_issue\".to_string(),\n+ original_name: \"create_issue\".to_string(),\n+ },\n+ fabro_agent::McpToolSummary {\n+ name: \"mcp__github__list_issues\".to_string(),\n+ original_name: \"list_issues\".to_string(),\n+ },\n+ ],\n+ },\n+ session_id: Some(\"ses_1\".to_string()),\n+ parent_session_id: None,\n+ tool_call_id: None,\n+ });\n+ assert_eq!(stored.event_name(), \"agent.mcp.ready\");\n+ match stored.body {\n+ EventBody::AgentMcpReady(props) => {\n+ assert_eq!(props.visit, 5);\n+ assert_eq!(props.server_name, \"github\");\n+ assert_eq!(props.tool_count, 2);\n+ assert_eq!(props.tools.len(), 2);\n+ assert_eq!(props.tools[0].name, \"mcp__github__create_issue\");\n+ assert_eq!(props.tools[0].original_name, \"create_issue\");\n+ assert_eq!(props.tools[1].name, \"mcp__github__list_issues\");\n+ }\n+ other => panic!(\"expected AgentMcpReady body, got {other:?}\"),\n+ }\n+ }\n }\ndiff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs\nindex 761372250..d730c1872 100644\n--- a/lib/crates/fabro-workflow/src/event/names.rs\n+++ b/lib/crates/fabro-workflow/src/event/names.rs\n@@ -89,6 +89,9 @@ pub fn event_name(event: &Event) -> &'static str {\n AgentEvent::SubAgentClosed { .. } => \"agent.sub.closed\",\n AgentEvent::McpServerReady { .. } => \"agent.mcp.ready\",\n AgentEvent::McpServerFailed { .. } => \"agent.mcp.failed\",\n+ AgentEvent::MemoryLoaded { .. } => \"agent.memory.loaded\",\n+ AgentEvent::SkillsDiscovered { .. } => \"agent.skills.discovered\",\n+ AgentEvent::SkillActivated { .. } => \"agent.skill.activated\",\n AgentEvent::TodoCreated(_) => \"todo.created\",\n AgentEvent::TodoUpdated(_) => \"todo.updated\",\n AgentEvent::TodoDeleted(_) => \"todo.deleted\",\ndiff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs\nindex 16979d9e5..625e4597f 100644\n--- a/lib/crates/fabro-workflow/src/handler/prompt.rs\n+++ b/lib/crates/fabro-workflow/src/handler/prompt.rs\n@@ -94,7 +94,12 @@ impl Handler for PromptHandler {\n if docs.is_empty() {\n None\n } else {\n- Some(docs.join(\"\\n\\n\"))\n+ Some(\n+ docs.into_iter()\n+ .map(|doc| doc.content)\n+ .collect::>()\n+ .join(\"\\n\\n\"),\n+ )\n }\n } else {\n None\n", + "summary": { + "files_changed": 12, + "additions": 1161, + "deletions": 42 + } + } + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-05-22T19:38:59.337710Z", + "current_node": "simplify_opus", + "completed_nodes": [ + "start", + "toolchain", + "preflight_compile", + "preflight_lint", + "implement", + "simplify_opus" + ], + "node_retries": {}, + "context_values": { "thread.toolchain.current_node": "preflight_compile", "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", "response.implement": "Let me do a quick sanity check on the plan's \"final sanity checks\":\n\n1. ✅ Memory events never contain file contents — verified by test `agent_memory_loaded_payload_excludes_file_contents` and structural design.\n2. ✅ Skills discovered and memory loaded emitted even for empty lists — verified by tests `initialize_emits_memory_loaded_event_with_empty_files_when_no_memory` and `initialize_emits_skills_discovered_event_when_no_skills`.\n3. ✅ Skill activation is persisted rather than filtered as streaming noise — `SkillActivated` is not in `is_streaming_noise()` and has conversion + name mapping.\n4. ✅ `agent.mcp.ready` backwards-compatible — verified by `agent_mcp_ready_deserializes_legacy_payload_without_tools` test and `#[serde(default, skip_serializing_if = \"Vec::is_empty\")]`.\n5. ✅ Docs match the serialized event names and payload shapes.\n\nSummary of changes:\n\n**Task 1 (fabro-types):** Added `AgentMemoryLoadedProps`, `AgentMemoryFileProps`, `AgentSkillsDiscoveredProps`, `AgentSkillSummary`, `AgentSkillActivationSource`, `AgentSkillActivatedProps`, `AgentMcpToolSummary`. Extended `AgentMcpReadyProps` with a backwards-compatible `tools` field. Added 3 new `EventBody` variants (`AgentMemoryLoaded`, `AgentSkillsDiscovered`, `AgentSkillActivated`) plus the legacy whitelist entries. Added 5 serde tests.\n\n**Task 2 (fabro-agent / fabro-workflow):** Added internal `AgentEvent::MemoryLoaded`, `SkillsDiscovered`, `SkillActivated` variants plus support structs (`MemoryFileSummary`, `SkillSummary`, `McpToolSummary`, `SkillActivationSource`). Extended `AgentEvent::McpServerReady` with `tools`. Added tracing for the new variants. Updated workflow `convert.rs` and `names.rs`. Added 4 conversion tests.\n\n**Task 3 (memory):** Changed `discover_memory` to return `Vec` with `{path, content, byte_count, loaded_bytes, truncated}`. `Session::initialize` emits `MemoryLoaded` immediately after discovery, always (even for empty results). The prompt assembly receives content-only strings. Memory test added for truncation metadata.\n\n**Task 4 (skills):** `Session::initialize` emits `SkillsDiscovered` (always). Slash expansion now emits `SkillActivated { Slash }` instead of `SkillExpanded`. `make_use_skill_tool` emits `SkillActivated { Tool }` only on successful lookup. Failed lookups do not emit. Added 4 session tests.\n\n**Task 5 (MCP):** Added `McpConnectionManager::tool_summaries_for_server` that filters/sorts by qualified name. `Session::initialize` includes the summaries in `McpServerReady`. Existing MCP e2e test now asserts the names-only summary content. Added an mcp unit test.\n\n**Task 6 (docs):** Updated `docs/internal/events.md` with the enriched `agent.mcp.ready` payload, three new sections for `agent.memory.loaded`, `agent.skills.discovered`, `agent.skill.activated`, and a note about the retired `agent.skill.expanded` notification.\n\n**Task 7:** All 1841 tests in `fabro-agent`, `fabro-workflow`, `fabro-types`, `fabro-mcp` pass; workspace clippy clean under nightly-2026-04-14; formatting checked.", - "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", - "last_response": "Let me do a quick sanity check on the plan's \"final sanity checks\":\n\n1. ✅ Memory events never contain file contents — verified by test `agent_memory_loaded_payload_excludes_file_contents` and stru", + "last_response": "All checks green. Here's the summary:\n\n## Summary\n\nThree review agents flagged overlapping issues. I applied the high-value cleanups and skipped low-value or out-of-scope ones.\n\n### Fixed\n1. **Elimina", "failure_class": "", "internal.work_dir": "/home/daytona/workspace/fabro", - "failure_signature": "", - "internal.thread_id": "preflight_lint", + "internal.retry_count.simplify_opus": 0, + "internal.thread_id": "implement", "internal.retry_count.preflight_compile": 0, + "internal.fidelity": "compact", + "outcome": "succeeded", + "internal.retry_count.implement": 0, + "current_node": "simplify_opus", + "graph.rankdir": "LR", + "internal.run_id": "01KS8GR1P97NYP93VVXWYCZHYZ", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "thread.implement.current_node": "simplify_opus", + "failure_signature": "", + "internal.retry_count.preflight_lint": 0, "internal.node_visit_count": 1, "internal.retry_count.start": 0, "internal.retry_count.toolchain": 0, - "internal.fidelity": "compact", - "internal.retry_count.preflight_lint": 0, - "last_stage": "implement", - "outcome": "succeeded", - "internal.retry_count.implement": 0, - "current_node": "implement", + "last_stage": "simplify_opus", "thread.start.current_node": "toolchain", "thread.preflight_lint.current_node": "implement", "thread.preflight_compile.current_node": "preflight_lint", "graph.goal": "# Agent Context Observability Events Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Add durable API/backend events that report loaded memory files, discovered and activated skills, and per-server MCP tool names for agent runs.\n\n**Architecture:** Keep this API-backend scoped. Emit typed `AgentEvent` variants from the existing `fabro-agent` initialization and skill activation paths, convert them through `fabro-workflow` into durable `fabro-types` run events, and document the event contracts. Do not add run projection fields in this pass; consumers can read the event stream/history.\n\n**Tech Stack:** Rust, Serde, Fabro agent/session events, Fabro workflow event conversion, Fabro MCP connection manager, `cargo nextest`.\n\n---\n\n## Scope\n\nImplement these event changes:\n\n- Add `agent.memory.loaded` with memory file paths, byte counts, loaded byte counts, truncation flags, provider profile, total loaded bytes, and budget bytes.\n- Add `agent.skills.discovered` with source directories, provider profile, and sorted skill summaries.\n- Add persisted `agent.skill.activated` for slash skill expansion and successful `use_skill` tool calls.\n- Enrich `agent.mcp.ready` with names-only tool summaries: qualified tool name and original server tool name.\n\nDo not implement ACP-native equivalents in this pass. Do not include memory file contents in any event payload. Do not include MCP tool descriptions or schemas.\n\n## Existing Patterns To Follow\n\n- Read `docs/internal/events-strategy.md` before changing event variants, names, conversion, or progress JSONL behavior.\n- Read `docs/internal/testing-strategy.md` before adding or reorganizing tests.\n- Follow the current `AgentEvent` flow:\n - `lib/crates/fabro-agent/src/types.rs`\n - `lib/crates/fabro-agent/src/session.rs`\n - `lib/crates/fabro-workflow/src/handler/llm/api.rs`\n - `lib/crates/fabro-workflow/src/event/convert.rs`\n - `lib/crates/fabro-workflow/src/event/names.rs`\n - `lib/crates/fabro-types/src/run_event/agent.rs`\n - `lib/crates/fabro-types/src/run_event/mod.rs`\n- Follow Rust import style from `AGENTS.md`: import types by name, import functions through their parent module, and avoid glob imports in production code.\n\n## File Map\n\n- Modify `lib/crates/fabro-types/src/run_event/agent.rs`: add new prop structs and extend `AgentMcpReadyProps`.\n- Modify `lib/crates/fabro-types/src/run_event/mod.rs`: add `EventBody` variants for the new event names.\n- Modify `lib/crates/fabro-agent/src/types.rs`: add internal `AgentEvent` variants, trace output, and noise filtering decisions.\n- Modify `lib/crates/fabro-agent/src/memory.rs`: return memory content plus metadata instead of bare strings.\n- Modify `lib/crates/fabro-agent/src/session.rs`: emit memory, skills, skill activation, and enriched MCP events.\n- Modify `lib/crates/fabro-agent/src/skills.rs`: emit tool-sourced skill activation from `use_skill`.\n- Modify `lib/crates/fabro-mcp/src/connection_manager.rs`: expose or support deterministic names-only tool summaries per server.\n- Modify `lib/crates/fabro-workflow/src/event/convert.rs`: convert new agent events to durable event bodies.\n- Modify `lib/crates/fabro-workflow/src/event/names.rs`: add event names.\n- Modify `lib/crates/fabro-workflow/src/event/events.rs` only if the agent event name mapping also lives there for these variants.\n- Modify `lib/crates/fabro-workflow/src/event/stored_fields.rs` only if a new event needs non-standard stored fields; otherwise rely on existing `Event::Agent` handling.\n- Modify `docs/internal/events.md`: document new event shapes and the richer MCP payload.\n- Add or update tests in `lib/crates/fabro-agent`, `lib/crates/fabro-mcp`, `lib/crates/fabro-types`, and `lib/crates/fabro-workflow`.\n\n---\n\n### Task 1: Add Typed Durable Event Contracts\n\n**Files:**\n- Modify: `lib/crates/fabro-types/src/run_event/agent.rs`\n- Modify: `lib/crates/fabro-types/src/run_event/mod.rs`\n- Test: existing `fabro-types` run event serde tests, or add focused coverage near the existing run event tests.\n\n- [ ] **Step 1: Add agent memory props**\n\nAdd event prop structs with this shape:\n\n```rust\npub struct AgentMemoryLoadedProps {\n pub provider_profile: String,\n pub files: Vec,\n pub total_loaded_bytes: usize,\n pub budget_bytes: usize,\n pub visit: u32,\n}\n\npub struct AgentMemoryFileProps {\n pub path: String,\n pub byte_count: usize,\n pub loaded_bytes: usize,\n pub truncated: bool,\n}\n```\n\n- [ ] **Step 2: Add skill props**\n\nAdd skill discovery and activation props:\n\n```rust\npub struct AgentSkillsDiscoveredProps {\n pub provider_profile: String,\n pub source_dirs: Vec,\n pub skills: Vec,\n pub visit: u32,\n}\n\npub struct AgentSkillSummary {\n pub name: String,\n pub description: String,\n}\n\npub enum AgentSkillActivationSource {\n Slash,\n Tool,\n}\n\npub struct AgentSkillActivatedProps {\n pub skill_name: String,\n pub source: AgentSkillActivationSource,\n pub visit: u32,\n}\n```\n\nUse serde names `slash` and `tool` for `AgentSkillActivationSource`. If a local enum string pattern already exists, follow that pattern.\n\n- [ ] **Step 3: Extend MCP ready props**\n\nExtend `AgentMcpReadyProps` with a backwards-compatible field:\n\n```rust\n#[serde(default, skip_serializing_if = \"Vec::is_empty\")]\npub tools: Vec,\n```\n\nAdd:\n\n```rust\npub struct AgentMcpToolSummary {\n pub name: String,\n pub original_name: String,\n}\n```\n\n- [ ] **Step 4: Add EventBody variants**\n\nAdd `EventBody` variants using these serialized event names:\n\n- `agent.memory.loaded`\n- `agent.skills.discovered`\n- `agent.skill.activated`\n\nKeep existing `agent.mcp.ready` name unchanged and only enrich its props.\n\n- [ ] **Step 5: Add serde tests**\n\nCover:\n\n- New event names serialize to the expected dot names.\n- `AgentSkillActivationSource` serializes as `slash` and `tool`.\n- Old `agent.mcp.ready` JSON without `tools` still deserializes with `tools == []`.\n\n---\n\n### Task 2: Add Internal Agent Events And Conversion\n\n**Files:**\n- Modify: `lib/crates/fabro-agent/src/types.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/convert.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/names.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/events.rs` if needed by the existing name mapping.\n- Test: `lib/crates/fabro-workflow` event conversion tests.\n\n- [ ] **Step 1: Add internal AgentEvent variants**\n\nAdd variants equivalent to:\n\n```rust\nMemoryLoaded {\n provider_profile: String,\n files: Vec,\n total_loaded_bytes: usize,\n budget_bytes: usize,\n}\n\nSkillsDiscovered {\n provider_profile: String,\n source_dirs: Vec,\n skills: Vec,\n}\n\nSkillActivated {\n skill_name: String,\n source: SkillActivationSource,\n}\n\nMcpServerReady {\n server_name: String,\n tool_count: usize,\n tools: Vec,\n}\n```\n\nPrefer small shared internal structs near `AgentEvent` if that matches the existing file organization.\n\n- [ ] **Step 2: Persist skill activation**\n\nDo not classify `SkillActivated` as streaming noise. The existing `SkillExpanded` event is currently filtered before persistence; replace slash expansion emissions with `SkillActivated { source: Slash }` or keep `SkillExpanded` internal-only if removing it would create unnecessary churn.\n\n- [ ] **Step 3: Add trace behavior**\n\nUpdate `AgentEvent::trace` so the new events emit concise tracing summaries:\n\n- memory loaded: profile, file count, total loaded bytes, budget bytes\n- skills discovered: profile, skill count, source dir count\n- skill activated: name and source\n- MCP ready: server, count, and summary count\n\n- [ ] **Step 4: Convert to durable events**\n\nUpdate `fabro-workflow` event conversion so the new agent events map to the new `fabro-types` props and include `visit`.\n\n- [ ] **Step 5: Add conversion tests**\n\nCover each new event with a focused conversion assertion that checks:\n\n- durable event name\n- `visit`\n- core fields\n- no memory content in the converted payload\n\n---\n\n### Task 3: Emit Memory Loaded Metadata\n\n**Files:**\n- Modify: `lib/crates/fabro-agent/src/memory.rs`\n- Modify: `lib/crates/fabro-agent/src/session.rs`\n- Test: relevant `fabro-agent` memory/session tests.\n\n- [ ] **Step 1: Change memory discovery return type**\n\nChange memory discovery from bare `Vec` to a document type carrying both prompt content and event metadata:\n\n```rust\npub struct MemoryDocument {\n pub path: String,\n pub content: String,\n pub byte_count: usize,\n pub loaded_bytes: usize,\n pub truncated: bool,\n}\n```\n\nKeep existing behavior unchanged:\n\n- provider profile filename candidates stay the same\n- root-to-working-dir walk stays the same\n- content dedupe stays the same\n- empty files are skipped\n- total budget remains 32 KiB\n- truncated content keeps the existing truncation marker\n\n- [ ] **Step 2: Preserve prompt assembly behavior**\n\nAdjust session/profile prompt assembly to pass only memory contents where prompt assembly expects memory text. The system prompt should be byte-for-byte equivalent except where existing tests allow non-semantic ordering differences.\n\n- [ ] **Step 3: Emit agent.memory.loaded**\n\nIn `Session::initialize()`, emit `AgentEvent::MemoryLoaded` immediately after memory discovery, before skills and MCP initialization.\n\nEmit the event even when no memory files are loaded. That lets consumers distinguish \"no memory\" from \"not reported.\"\n\n- [ ] **Step 4: Add memory tests**\n\nCover:\n\n- loaded file path appears in event metadata\n- `byte_count` is the original file byte count\n- `loaded_bytes` reflects bytes actually loaded into the prompt budget\n- `truncated` is true only for truncated files\n- event payload never contains memory file contents\n- empty discovery still emits a memory-loaded event with `files == []`\n\n---\n\n### Task 4: Emit Skills Discovered And Skill Activated\n\n**Files:**\n- Modify: `lib/crates/fabro-agent/src/session.rs`\n- Modify: `lib/crates/fabro-agent/src/skills.rs`\n- Test: relevant `fabro-agent` skill/session tests.\n\n- [ ] **Step 1: Emit skills discovered**\n\nAfter `discover_skills(...)`, emit `AgentEvent::SkillsDiscovered` with:\n\n- `provider_profile`\n- `source_dirs`\n- sorted `skills: [{ name, description }]`\n\nEmit the event even when no skills are discovered.\n\n- [ ] **Step 2: Emit slash activation**\n\nWhere slash skill expansion currently emits or creates `SkillExpanded`, emit:\n\n```rust\nAgentEvent::SkillActivated {\n skill_name,\n source: SkillActivationSource::Slash,\n}\n```\n\n- [ ] **Step 3: Emit tool activation**\n\nIn `make_use_skill_tool`, use `ToolContext::emit_agent_event(...)` after a requested skill is found and before returning the skill template. Emit:\n\n```rust\nAgentEvent::SkillActivated {\n skill_name: name.to_string(),\n source: SkillActivationSource::Tool,\n}\n```\n\nDo not emit activation for failed `use_skill` lookups.\n\n- [ ] **Step 4: Add skill tests**\n\nCover:\n\n- discovery event includes all discovered skills sorted by name\n- discovery event includes configured source directories\n- empty discovery emits `skills == []`\n- slash expansion emits `source == slash`\n- successful `use_skill` emits `source == tool`\n- failed `use_skill` does not emit activation\n\n---\n\n### Task 5: Enrich agent.mcp.ready With Names-Only Tool Summaries\n\n**Files:**\n- Modify: `lib/crates/fabro-mcp/src/connection_manager.rs`\n- Modify: `lib/crates/fabro-agent/src/session.rs`\n- Test: relevant `fabro-mcp` or `fabro-agent` MCP tests.\n\n- [ ] **Step 1: Add deterministic tool summaries**\n\nExpose a helper on `McpConnectionManager` or compute in `Session` from `all_tools()`:\n\n- filter tools by `server_name`\n- return qualified tool name as `name`\n- return server-provided tool name as `original_name`\n- sort by qualified `name`\n\n- [ ] **Step 2: Enrich ready emissions**\n\nWhen emitting `AgentEvent::McpServerReady`, include the tool summaries for that server. Keep existing `server_name` and `tool_count`.\n\n- [ ] **Step 3: Add MCP tests**\n\nCover:\n\n- ready event includes only tools from the ready server\n- summaries are sorted by qualified name\n- `name` is the Fabro-qualified MCP tool name\n- `original_name` is the server-provided tool name\n- descriptions and input schemas are not included\n\n---\n\n### Task 6: Update Event Documentation\n\n**Files:**\n- Modify: `docs/internal/events.md`\n\n- [ ] **Step 1: Document new events**\n\nAdd sections for:\n\n- `agent.memory.loaded`\n- `agent.skills.discovered`\n- `agent.skill.activated`\n\nFor `agent.memory.loaded`, explicitly state that file contents are excluded.\n\n- [ ] **Step 2: Update MCP ready docs**\n\nUpdate `agent.mcp.ready` to show:\n\n```json\n{\n \"server_name\": \"github\",\n \"tool_count\": 2,\n \"tools\": [\n {\n \"name\": \"mcp__github__create_issue\",\n \"original_name\": \"create_issue\"\n }\n ],\n \"visit\": 1\n}\n```\n\n- [ ] **Step 3: Record skill event replacement**\n\nIf `agent.skill.expanded` remains in internal code or docs, mark it internal-only or replaced by `agent.skill.activated`.\n\n---\n\n### Task 7: Verify\n\n**Files:**\n- No new files unless test placement requires it.\n\n- [ ] **Step 1: Run focused tests**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-agent -p fabro-workflow -p fabro-types -p fabro-mcp\n```\n\n- [ ] **Step 2: Run formatting**\n\nRun:\n\n```bash\ncargo +nightly-2026-04-14 fmt --all\n```\n\n- [ ] **Step 3: Run clippy for touched crates or workspace**\n\nPrefer the workspace command if time permits:\n\n```bash\ncargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings\n```\n\n- [ ] **Step 4: Final sanity checks**\n\nConfirm:\n\n- memory events never contain file contents\n- skills discovered and memory loaded are emitted even for empty lists\n- skill activation is persisted rather than filtered as streaming noise\n- `agent.mcp.ready` remains backwards-compatible for old events without `tools`\n- docs match the serialized event names and payload shapes\n\n", - "graph.rankdir": "LR" + "response.simplify_opus": "All checks green. Here's the summary:\n\n## Summary\n\nThree review agents flagged overlapping issues. I applied the high-value cleanups and skipped low-value or out-of-scope ones.\n\n### Fixed\n1. **Eliminated 4 duplicate types** between `fabro-agent` and `fabro-types` (`McpToolSummary`, `MemoryFileSummary`, `SkillSummary`, `SkillActivationSource`). `AgentEvent` now embeds the `fabro-types` versions directly, mirroring the existing `TodoCreated/Updated/Deleted` pattern. Removed ~50 lines of field-by-field cloning in `convert.rs`.\n2. **Deleted dead `SkillExpanded` variant** entirely — its emit site had already been replaced with `SkillActivated { source: Slash }`. Removed from `types.rs` (variant, streaming-noise classification, trace arm, test), `convert.rs` (panic-list), and `names.rs` (name mapping).\n3. **Strongly typed `provider_profile`** as `fabro_model::AgentProfileKind` (Copy enum with existing `Display`) on both wire props and in-memory variants instead of `String`. Removed two `.to_string()` calls and per-event clones.\n4. **Dropped redundant derived fields** from in-memory variants:\n - `MemoryDocument.loaded_bytes` → always equals `content.len()`.\n - `AgentEvent::McpServerReady.tool_count` → always equals `tools.len()`.\n - `AgentEvent::MemoryLoaded.total_loaded_bytes` → sum of `files[].loaded_bytes`.\n - All three are computed at the conversion boundary so the wire shape is preserved.\n\n### Skipped (with reason)\n- **`build_system_prompt` signature change** (memory clone elimination): touches 6 files including public SDK reference docs and 4 profile impls for a single 32 KiB clone at session init. Not worth the API churn.\n- **MCP tool N+1 fix**: one-shot pass at session startup, not a hot path.\n- **`ToolContext` Option accretion**: out of scope for this PR; should be its own refactor when the next field is added.\n- **Two-pass over memory in init**: micro-optimization, current code is clearer.\n\n### Verification\n- `cargo check --workspace --all-targets` — clean\n- `cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-types -p fabro-mcp` — 1840 passed, 0 failed\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` — clean\n- `cargo +nightly-2026-04-14 fmt --all` — applied\n\nNet change: removed ~100 lines while preserving all behavior, wire format, and test coverage." }, "node_outcomes": { "preflight_lint": { @@ -813,6 +952,50 @@ "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", "usage": null }, + "simplify_opus": { + "status": "succeeded", + "context_updates": { + "response.simplify_opus": "All checks green. Here's the summary:\n\n## Summary\n\nThree review agents flagged overlapping issues. I applied the high-value cleanups and skipped low-value or out-of-scope ones.\n\n### Fixed\n1. **Eliminated 4 duplicate types** between `fabro-agent` and `fabro-types` (`McpToolSummary`, `MemoryFileSummary`, `SkillSummary`, `SkillActivationSource`). `AgentEvent` now embeds the `fabro-types` versions directly, mirroring the existing `TodoCreated/Updated/Deleted` pattern. Removed ~50 lines of field-by-field cloning in `convert.rs`.\n2. **Deleted dead `SkillExpanded` variant** entirely — its emit site had already been replaced with `SkillActivated { source: Slash }`. Removed from `types.rs` (variant, streaming-noise classification, trace arm, test), `convert.rs` (panic-list), and `names.rs` (name mapping).\n3. **Strongly typed `provider_profile`** as `fabro_model::AgentProfileKind` (Copy enum with existing `Display`) on both wire props and in-memory variants instead of `String`. Removed two `.to_string()` calls and per-event clones.\n4. **Dropped redundant derived fields** from in-memory variants:\n - `MemoryDocument.loaded_bytes` → always equals `content.len()`.\n - `AgentEvent::McpServerReady.tool_count` → always equals `tools.len()`.\n - `AgentEvent::MemoryLoaded.total_loaded_bytes` → sum of `files[].loaded_bytes`.\n - All three are computed at the conversion boundary so the wire shape is preserved.\n\n### Skipped (with reason)\n- **`build_system_prompt` signature change** (memory clone elimination): touches 6 files including public SDK reference docs and 4 profile impls for a single 32 KiB clone at session init. Not worth the API churn.\n- **MCP tool N+1 fix**: one-shot pass at session startup, not a hot path.\n- **`ToolContext` Option accretion**: out of scope for this PR; should be its own refactor when the next field is added.\n- **Two-pass over memory in init**: micro-optimization, current code is clearer.\n\n### Verification\n- `cargo check --workspace --all-targets` — clean\n- `cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-types -p fabro-mcp` — 1840 passed, 0 failed\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` — clean\n- `cargo +nightly-2026-04-14 fmt --all` — applied\n\nNet change: removed ~100 lines while preserving all behavior, wire format, and test coverage.", + "last_response": "All checks green. Here's the summary:\n\n## Summary\n\nThree review agents flagged overlapping issues. I applied the high-value cleanups and skipped low-value or out-of-scope ones.\n\n### Fixed\n1. **Elimina", + "last_stage": "simplify_opus" + }, + "notes": "Stage completed: simplify_opus", + "usage": { + "input": { + "usage": { + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "tokens": { + "input_tokens": 119287, + "output_tokens": 38365, + "reasoning_tokens": 0, + "cache_read_tokens": 8304451, + "cache_write_tokens": 129921 + } + }, + "facts": { + "algorithm": "anthropic", + "cache_write_5m_tokens": 129921, + "cache_write_1h_tokens": 0 + } + }, + "total_usd_micros": 6519791 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/lib.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/memory.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/session.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/skills.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-agent/src/types.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-types/src/lib.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-types/src/run_event/agent.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-types/src/run_event/mod.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event/convert.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event/names.rs" + ] + }, "start": { "status": "succeeded", "usage": null @@ -880,13 +1063,14 @@ ] } }, - "next_node_id": "simplify_opus", + "next_node_id": "simplify_gpt", "node_visits": { "start": 1, "toolchain": 1, "preflight_compile": 1, "preflight_lint": 1, - "implement": 1 + "implement": 1, + "simplify_opus": 1 } }, "diff": {} @@ -1009,6 +1193,38 @@ }, "state": "succeeded" }, + "simplify_opus@1": { + "first_event_seq": 498, + "prompt": null, + "response": null, + "completion": null, + "provider_used": { + "mode": "agent", + "provider": "anthropic", + "model": "claude-opus-4-7" + }, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-05-22T19:23:37.882517Z", + "handler": "agent", + "usage": { + "input_tokens": 119287, + "output_tokens": 38365, + "total_tokens": 8592024, + "reasoning_tokens": 0, + "cache_read_tokens": 8304451, + "cache_write_tokens": 129921, + "total_usd_micros": 6519791 + }, + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "state": "running" + }, "start@1": { "first_event_seq": 16, "prompt": null, @@ -1047,7 +1263,12 @@ "first_event_seq": 50, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-05-22T19:23:33.869268Z" + }, "provider_used": { "mode": "agent", "provider": "anthropic", @@ -1060,6 +1281,12 @@ "output": null, "started_at": "2026-05-22T19:02:15.512182Z", "handler": "agent", + "timing": { + "wall_time_ms": 1278352, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, "usage": { "input_tokens": 220090, "output_tokens": 51681, @@ -1073,7 +1300,7 @@ "provider": "anthropic", "model_id": "claude-opus-4-7" }, - "state": "running" + "state": "succeeded" }, "preflight_lint@1": { "first_event_seq": 40, diff --git a/stages/005-implement@1/diff.patch b/stages/005-implement@1/diff.patch new file mode 100644 index 000000000..94bd6b8b4 --- /dev/null +++ b/stages/005-implement@1/diff.patch @@ -0,0 +1,1638 @@ +diff --git a/docs/internal/events.md b/docs/internal/events.md +index f9e99fd89..b6ed547d1 100644 +--- a/docs/internal/events.md ++++ b/docs/internal/events.md +@@ -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 | +\ No newline at end of file +diff --git a/lib/crates/fabro-agent/src/lib.rs b/lib/crates/fabro-agent/src/lib.rs +index 34de5062c..00be22526 100644 +--- a/lib/crates/fabro-agent/src/lib.rs ++++ b/lib/crates/fabro-agent/src/lib.rs +@@ -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( +diff --git a/lib/crates/fabro-agent/src/memory.rs b/lib/crates/fabro-agent/src/memory.rs +index a86626846..07e59c6da 100644 +--- a/lib/crates/fabro-agent/src/memory.rs ++++ b/lib/crates/fabro-agent/src/memory.rs +@@ -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, Error> { ++) -> Result, 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 = 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, "Agent instructions".len()); ++ 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 = 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 = 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].loaded_bytes); + // 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].loaded_bytes); ++ 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 = 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].loaded_bytes < docs[0].byte_count); ++ assert!(docs[0].loaded_bytes <= 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"); + } + } +diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs +index 77d8ca00c..4163e70ed 100644 +--- a/lib/crates/fabro-agent/src/session.rs ++++ b/lib/crates/fabro-agent/src/session.rs +@@ -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>, + interrupt_reason: Arc>>, +- memory: Vec, ++ memory: Vec, + env_context: EnvContext, + skills: Vec, + system_prompt: String, +@@ -489,6 +492,29 @@ impl Session { + ) + .await?; + ++ let provider_profile_name = 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 = 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: usize = self.memory.iter().map(|doc| doc.loaded_bytes).sum(); ++ self.event_emitter ++ .emit(self.id.clone(), AgentEvent::MemoryLoaded { ++ provider_profile: provider_profile_name.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 = 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: provider_profile_name.clone(), ++ 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 = ++ 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,21 @@ 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, ++ 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 +3925,278 @@ mod tests { + "ProcessingEnd event should be emitted when returning to Idle" + ); + } ++ ++ async fn build_initialized_session( ++ sandbox: Arc, ++ 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, ++ total_loaded_bytes, ++ budget_bytes, ++ provider_profile, ++ } = envelope.event ++ { ++ memory_event = Some((files, total_loaded_bytes, budget_bytes, provider_profile)); ++ break; ++ } ++ } ++ let (files, total_loaded_bytes, budget_bytes, provider_profile) = ++ memory_event.expect("MemoryLoaded should be emitted"); ++ assert_eq!(provider_profile, "anthropic"); ++ assert_eq!(budget_bytes, 32768); ++ assert_eq!(total_loaded_bytes, "Hello world".len()); ++ 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::::new()); ++ let tool = make_use_skill_tool(skills_arc); ++ let mut rx = session.subscribe(); ++ let env: Arc = 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"); ++ } ++ } ++ } + } +diff --git a/lib/crates/fabro-agent/src/skills.rs b/lib/crates/fabro-agent/src/skills.rs +index 4bb2ff8f4..f7b4e3eaf 100644 +--- a/lib/crates/fabro-agent/src/skills.rs ++++ b/lib/crates/fabro-agent/src/skills.rs +@@ -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>) -> 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>) -> 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()) + }) + }), +diff --git a/lib/crates/fabro-agent/src/types.rs b/lib/crates/fabro-agent/src/types.rs +index 1f11584d1..95833a2d8 100644 +--- a/lib/crates/fabro-agent/src/types.rs ++++ b/lib/crates/fabro-agent/src/types.rs +@@ -194,6 +194,33 @@ pub enum SessionState { + Closed, + } + ++#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] ++pub struct MemoryFileSummary { ++ pub path: String, ++ pub byte_count: usize, ++ pub loaded_bytes: usize, ++ pub truncated: bool, ++} ++ ++#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] ++pub struct SkillSummary { ++ pub name: String, ++ pub description: String, ++} ++ ++#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] ++pub struct McpToolSummary { ++ pub name: String, ++ pub original_name: String, ++} ++ ++#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] ++#[serde(rename_all = "snake_case")] ++pub enum SkillActivationSource { ++ Slash, ++ Tool, ++} ++ + #[derive(Debug, Clone, Serialize, Deserialize)] + pub enum AgentEvent { + SessionStarted { +@@ -303,11 +330,27 @@ pub enum AgentEvent { + McpServerReady { + server_name: String, + tool_count: usize, ++ tools: Vec, + }, + McpServerFailed { + server_name: String, + error: String, + }, ++ MemoryLoaded { ++ provider_profile: String, ++ files: Vec, ++ total_loaded_bytes: usize, ++ budget_bytes: usize, ++ }, ++ SkillsDiscovered { ++ provider_profile: String, ++ source_dirs: Vec, ++ skills: Vec, ++ }, ++ 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), +@@ -504,14 +547,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.as_str(), ++ 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, +@@ -752,6 +833,7 @@ mod tests { + let event = AgentEvent::McpServerReady { + server_name: "filesystem".into(), + tool_count: 3, ++ tools: Vec::new(), + }; + assert!(matches!(event, AgentEvent::McpServerReady { + tool_count: 3, +@@ -776,6 +858,7 @@ mod tests { + AgentEvent::McpServerReady { + server_name: "fs".into(), + tool_count: 5, ++ tools: Vec::new(), + }, + AgentEvent::McpServerFailed { + server_name: "bad".into(), +diff --git a/lib/crates/fabro-mcp/src/connection_manager.rs b/lib/crates/fabro-mcp/src/connection_manager.rs +index 59e12f6cc..3eff94b30 100644 +--- a/lib/crates/fabro-mcp/src/connection_manager.rs ++++ b/lib/crates/fabro-mcp/src/connection_manager.rs +@@ -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()); ++ } + } +diff --git a/lib/crates/fabro-types/src/run_event/agent.rs b/lib/crates/fabro-types/src/run_event/agent.rs +index eb4fe13c2..56106dc60 100644 +--- a/lib/crates/fabro-types/src/run_event/agent.rs ++++ b/lib/crates/fabro-types/src/run_event/agent.rs +@@ -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, + 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, ++ 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, ++ pub skills: Vec, ++ 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, ++} +diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs +index 4cfd89b00..564dea99a 100644 +--- a/lib/crates/fabro-types/src/run_event/mod.rs ++++ b/lib/crates/fabro-types/src/run_event/mod.rs +@@ -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::(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" ++ ); ++ } + } +diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs +index 83eefb759..c1e008159 100644 +--- a/lib/crates/fabro-workflow/src/event/convert.rs ++++ b/lib/crates/fabro-workflow/src/event/convert.rs +@@ -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(), ++ 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(), ++ total_loaded_bytes: *total_loaded_bytes, ++ 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 { ++ fabro_agent::SkillActivationSource::Slash => { ++ fabro_types::AgentSkillActivationSource::Slash ++ } ++ fabro_agent::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()), +@@ -2262,4 +2320,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![fabro_agent::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![fabro_agent::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![fabro_agent::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: fabro_agent::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: fabro_agent::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![ ++ fabro_agent::McpToolSummary { ++ name: "mcp__github__create_issue".to_string(), ++ original_name: "create_issue".to_string(), ++ }, ++ fabro_agent::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:?}"), ++ } ++ } + } +diff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs +index 761372250..d730c1872 100644 +--- a/lib/crates/fabro-workflow/src/event/names.rs ++++ b/lib/crates/fabro-workflow/src/event/names.rs +@@ -89,6 +89,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", +diff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs +index 16979d9e5..625e4597f 100644 +--- a/lib/crates/fabro-workflow/src/handler/prompt.rs ++++ b/lib/crates/fabro-workflow/src/handler/prompt.rs +@@ -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::>() ++ .join("\n\n"), ++ ) + } + } else { + None diff --git a/stages/005-implement@1/status.json b/stages/005-implement@1/status.json new file mode 100644 index 000000000..2c3f88d86 --- /dev/null +++ b/stages/005-implement@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-05-22T19:23:33.869268Z" +} \ No newline at end of file diff --git a/stages/006-simplify_opus@1/prompt.md b/stages/006-simplify_opus@1/prompt.md new file mode 100644 index 000000000..5991af56c --- /dev/null +++ b/stages/006-simplify_opus@1/prompt.md @@ -0,0 +1,508 @@ +Goal: # Agent Context Observability Events Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add durable API/backend events that report loaded memory files, discovered and activated skills, and per-server MCP tool names for agent runs. + +**Architecture:** Keep this API-backend scoped. Emit typed `AgentEvent` variants from the existing `fabro-agent` initialization and skill activation paths, convert them through `fabro-workflow` into durable `fabro-types` run events, and document the event contracts. Do not add run projection fields in this pass; consumers can read the event stream/history. + +**Tech Stack:** Rust, Serde, Fabro agent/session events, Fabro workflow event conversion, Fabro MCP connection manager, `cargo nextest`. + +--- + +## Scope + +Implement these event changes: + +- Add `agent.memory.loaded` with memory file paths, byte counts, loaded byte counts, truncation flags, provider profile, total loaded bytes, and budget bytes. +- Add `agent.skills.discovered` with source directories, provider profile, and sorted skill summaries. +- Add persisted `agent.skill.activated` for slash skill expansion and successful `use_skill` tool calls. +- Enrich `agent.mcp.ready` with names-only tool summaries: qualified tool name and original server tool name. + +Do not implement ACP-native equivalents in this pass. Do not include memory file contents in any event payload. Do not include MCP tool descriptions or schemas. + +## Existing Patterns To Follow + +- Read `docs/internal/events-strategy.md` before changing event variants, names, conversion, or progress JSONL behavior. +- Read `docs/internal/testing-strategy.md` before adding or reorganizing tests. +- Follow the current `AgentEvent` flow: + - `lib/crates/fabro-agent/src/types.rs` + - `lib/crates/fabro-agent/src/session.rs` + - `lib/crates/fabro-workflow/src/handler/llm/api.rs` + - `lib/crates/fabro-workflow/src/event/convert.rs` + - `lib/crates/fabro-workflow/src/event/names.rs` + - `lib/crates/fabro-types/src/run_event/agent.rs` + - `lib/crates/fabro-types/src/run_event/mod.rs` +- Follow Rust import style from `AGENTS.md`: import types by name, import functions through their parent module, and avoid glob imports in production code. + +## File Map + +- Modify `lib/crates/fabro-types/src/run_event/agent.rs`: add new prop structs and extend `AgentMcpReadyProps`. +- Modify `lib/crates/fabro-types/src/run_event/mod.rs`: add `EventBody` variants for the new event names. +- Modify `lib/crates/fabro-agent/src/types.rs`: add internal `AgentEvent` variants, trace output, and noise filtering decisions. +- Modify `lib/crates/fabro-agent/src/memory.rs`: return memory content plus metadata instead of bare strings. +- Modify `lib/crates/fabro-agent/src/session.rs`: emit memory, skills, skill activation, and enriched MCP events. +- Modify `lib/crates/fabro-agent/src/skills.rs`: emit tool-sourced skill activation from `use_skill`. +- Modify `lib/crates/fabro-mcp/src/connection_manager.rs`: expose or support deterministic names-only tool summaries per server. +- Modify `lib/crates/fabro-workflow/src/event/convert.rs`: convert new agent events to durable event bodies. +- Modify `lib/crates/fabro-workflow/src/event/names.rs`: add event names. +- Modify `lib/crates/fabro-workflow/src/event/events.rs` only if the agent event name mapping also lives there for these variants. +- Modify `lib/crates/fabro-workflow/src/event/stored_fields.rs` only if a new event needs non-standard stored fields; otherwise rely on existing `Event::Agent` handling. +- Modify `docs/internal/events.md`: document new event shapes and the richer MCP payload. +- Add or update tests in `lib/crates/fabro-agent`, `lib/crates/fabro-mcp`, `lib/crates/fabro-types`, and `lib/crates/fabro-workflow`. + +--- + +### Task 1: Add Typed Durable Event Contracts + +**Files:** +- Modify: `lib/crates/fabro-types/src/run_event/agent.rs` +- Modify: `lib/crates/fabro-types/src/run_event/mod.rs` +- Test: existing `fabro-types` run event serde tests, or add focused coverage near the existing run event tests. + +- [ ] **Step 1: Add agent memory props** + +Add event prop structs with this shape: + +```rust +pub struct AgentMemoryLoadedProps { + pub provider_profile: String, + pub files: Vec, + pub total_loaded_bytes: usize, + pub budget_bytes: usize, + pub visit: u32, +} + +pub struct AgentMemoryFileProps { + pub path: String, + pub byte_count: usize, + pub loaded_bytes: usize, + pub truncated: bool, +} +``` + +- [ ] **Step 2: Add skill props** + +Add skill discovery and activation props: + +```rust +pub struct AgentSkillsDiscoveredProps { + pub provider_profile: String, + pub source_dirs: Vec, + pub skills: Vec, + pub visit: u32, +} + +pub struct AgentSkillSummary { + pub name: String, + pub description: String, +} + +pub enum AgentSkillActivationSource { + Slash, + Tool, +} + +pub struct AgentSkillActivatedProps { + pub skill_name: String, + pub source: AgentSkillActivationSource, + pub visit: u32, +} +``` + +Use serde names `slash` and `tool` for `AgentSkillActivationSource`. If a local enum string pattern already exists, follow that pattern. + +- [ ] **Step 3: Extend MCP ready props** + +Extend `AgentMcpReadyProps` with a backwards-compatible field: + +```rust +#[serde(default, skip_serializing_if = "Vec::is_empty")] +pub tools: Vec, +``` + +Add: + +```rust +pub struct AgentMcpToolSummary { + pub name: String, + pub original_name: String, +} +``` + +- [ ] **Step 4: Add EventBody variants** + +Add `EventBody` variants using these serialized event names: + +- `agent.memory.loaded` +- `agent.skills.discovered` +- `agent.skill.activated` + +Keep existing `agent.mcp.ready` name unchanged and only enrich its props. + +- [ ] **Step 5: Add serde tests** + +Cover: + +- New event names serialize to the expected dot names. +- `AgentSkillActivationSource` serializes as `slash` and `tool`. +- Old `agent.mcp.ready` JSON without `tools` still deserializes with `tools == []`. + +--- + +### Task 2: Add Internal Agent Events And Conversion + +**Files:** +- Modify: `lib/crates/fabro-agent/src/types.rs` +- Modify: `lib/crates/fabro-workflow/src/event/convert.rs` +- Modify: `lib/crates/fabro-workflow/src/event/names.rs` +- Modify: `lib/crates/fabro-workflow/src/event/events.rs` if needed by the existing name mapping. +- Test: `lib/crates/fabro-workflow` event conversion tests. + +- [ ] **Step 1: Add internal AgentEvent variants** + +Add variants equivalent to: + +```rust +MemoryLoaded { + provider_profile: String, + files: Vec, + total_loaded_bytes: usize, + budget_bytes: usize, +} + +SkillsDiscovered { + provider_profile: String, + source_dirs: Vec, + skills: Vec, +} + +SkillActivated { + skill_name: String, + source: SkillActivationSource, +} + +McpServerReady { + server_name: String, + tool_count: usize, + tools: Vec, +} +``` + +Prefer small shared internal structs near `AgentEvent` if that matches the existing file organization. + +- [ ] **Step 2: Persist skill activation** + +Do not classify `SkillActivated` as streaming noise. The existing `SkillExpanded` event is currently filtered before persistence; replace slash expansion emissions with `SkillActivated { source: Slash }` or keep `SkillExpanded` internal-only if removing it would create unnecessary churn. + +- [ ] **Step 3: Add trace behavior** + +Update `AgentEvent::trace` so the new events emit concise tracing summaries: + +- memory loaded: profile, file count, total loaded bytes, budget bytes +- skills discovered: profile, skill count, source dir count +- skill activated: name and source +- MCP ready: server, count, and summary count + +- [ ] **Step 4: Convert to durable events** + +Update `fabro-workflow` event conversion so the new agent events map to the new `fabro-types` props and include `visit`. + +- [ ] **Step 5: Add conversion tests** + +Cover each new event with a focused conversion assertion that checks: + +- durable event name +- `visit` +- core fields +- no memory content in the converted payload + +--- + +### Task 3: Emit Memory Loaded Metadata + +**Files:** +- Modify: `lib/crates/fabro-agent/src/memory.rs` +- Modify: `lib/crates/fabro-agent/src/session.rs` +- Test: relevant `fabro-agent` memory/session tests. + +- [ ] **Step 1: Change memory discovery return type** + +Change memory discovery from bare `Vec` to a document type carrying both prompt content and event metadata: + +```rust +pub struct MemoryDocument { + pub path: String, + pub content: String, + pub byte_count: usize, + pub loaded_bytes: usize, + pub truncated: bool, +} +``` + +Keep existing behavior unchanged: + +- provider profile filename candidates stay the same +- root-to-working-dir walk stays the same +- content dedupe stays the same +- empty files are skipped +- total budget remains 32 KiB +- truncated content keeps the existing truncation marker + +- [ ] **Step 2: Preserve prompt assembly behavior** + +Adjust session/profile prompt assembly to pass only memory contents where prompt assembly expects memory text. The system prompt should be byte-for-byte equivalent except where existing tests allow non-semantic ordering differences. + +- [ ] **Step 3: Emit agent.memory.loaded** + +In `Session::initialize()`, emit `AgentEvent::MemoryLoaded` immediately after memory discovery, before skills and MCP initialization. + +Emit the event even when no memory files are loaded. That lets consumers distinguish "no memory" from "not reported." + +- [ ] **Step 4: Add memory tests** + +Cover: + +- loaded file path appears in event metadata +- `byte_count` is the original file byte count +- `loaded_bytes` reflects bytes actually loaded into the prompt budget +- `truncated` is true only for truncated files +- event payload never contains memory file contents +- empty discovery still emits a memory-loaded event with `files == []` + +--- + +### Task 4: Emit Skills Discovered And Skill Activated + +**Files:** +- Modify: `lib/crates/fabro-agent/src/session.rs` +- Modify: `lib/crates/fabro-agent/src/skills.rs` +- Test: relevant `fabro-agent` skill/session tests. + +- [ ] **Step 1: Emit skills discovered** + +After `discover_skills(...)`, emit `AgentEvent::SkillsDiscovered` with: + +- `provider_profile` +- `source_dirs` +- sorted `skills: [{ name, description }]` + +Emit the event even when no skills are discovered. + +- [ ] **Step 2: Emit slash activation** + +Where slash skill expansion currently emits or creates `SkillExpanded`, emit: + +```rust +AgentEvent::SkillActivated { + skill_name, + source: SkillActivationSource::Slash, +} +``` + +- [ ] **Step 3: Emit tool activation** + +In `make_use_skill_tool`, use `ToolContext::emit_agent_event(...)` after a requested skill is found and before returning the skill template. Emit: + +```rust +AgentEvent::SkillActivated { + skill_name: name.to_string(), + source: SkillActivationSource::Tool, +} +``` + +Do not emit activation for failed `use_skill` lookups. + +- [ ] **Step 4: Add skill tests** + +Cover: + +- discovery event includes all discovered skills sorted by name +- discovery event includes configured source directories +- empty discovery emits `skills == []` +- slash expansion emits `source == slash` +- successful `use_skill` emits `source == tool` +- failed `use_skill` does not emit activation + +--- + +### Task 5: Enrich agent.mcp.ready With Names-Only Tool Summaries + +**Files:** +- Modify: `lib/crates/fabro-mcp/src/connection_manager.rs` +- Modify: `lib/crates/fabro-agent/src/session.rs` +- Test: relevant `fabro-mcp` or `fabro-agent` MCP tests. + +- [ ] **Step 1: Add deterministic tool summaries** + +Expose a helper on `McpConnectionManager` or compute in `Session` from `all_tools()`: + +- filter tools by `server_name` +- return qualified tool name as `name` +- return server-provided tool name as `original_name` +- sort by qualified `name` + +- [ ] **Step 2: Enrich ready emissions** + +When emitting `AgentEvent::McpServerReady`, include the tool summaries for that server. Keep existing `server_name` and `tool_count`. + +- [ ] **Step 3: Add MCP tests** + +Cover: + +- ready event includes only tools from the ready server +- summaries are sorted by qualified name +- `name` is the Fabro-qualified MCP tool name +- `original_name` is the server-provided tool name +- descriptions and input schemas are not included + +--- + +### Task 6: Update Event Documentation + +**Files:** +- Modify: `docs/internal/events.md` + +- [ ] **Step 1: Document new events** + +Add sections for: + +- `agent.memory.loaded` +- `agent.skills.discovered` +- `agent.skill.activated` + +For `agent.memory.loaded`, explicitly state that file contents are excluded. + +- [ ] **Step 2: Update MCP ready docs** + +Update `agent.mcp.ready` to show: + +```json +{ + "server_name": "github", + "tool_count": 2, + "tools": [ + { + "name": "mcp__github__create_issue", + "original_name": "create_issue" + } + ], + "visit": 1 +} +``` + +- [ ] **Step 3: Record skill event replacement** + +If `agent.skill.expanded` remains in internal code or docs, mark it internal-only or replaced by `agent.skill.activated`. + +--- + +### Task 7: Verify + +**Files:** +- No new files unless test placement requires it. + +- [ ] **Step 1: Run focused tests** + +Run: + +```bash +cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-types -p fabro-mcp +``` + +- [ ] **Step 2: Run formatting** + +Run: + +```bash +cargo +nightly-2026-04-14 fmt --all +``` + +- [ ] **Step 3: Run clippy for touched crates or workspace** + +Prefer the workspace command if time permits: + +```bash +cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings +``` + +- [ ] **Step 4: Final sanity checks** + +Confirm: + +- memory events never contain file contents +- skills discovered and memory loaded are emitted even for empty lists +- skill activation is persisted rather than filtered as streaming noise +- `agent.mcp.ready` remains backwards-compatible for old events without `tools` +- docs match the serialized event names and payload shapes + + + +## Completed stages +- **toolchain**: succeeded + - 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` + - Output: + ``` + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + ``` +- **preflight_compile**: succeeded + - Script: `cargo check -q --workspace 2>&1` + - Output: (empty) +- **preflight_lint**: succeeded + - Script: `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1` + - Output: (empty) +- **implement**: succeeded + - Model: claude-opus-4-7, 220.1k tokens in / 51.7k out + - Files: /home/daytona/workspace/fabro/docs/internal/events.md, /home/daytona/workspace/fabro/lib/crates/fabro-agent/src/lib.rs, /home/daytona/workspace/fabro/lib/crates/fabro-agent/src/memory.rs, /home/daytona/workspace/fabro/lib/crates/fabro-agent/src/session.rs, /home/daytona/workspace/fabro/lib/crates/fabro-agent/src/skills.rs, /home/daytona/workspace/fabro/lib/crates/fabro-agent/src/types.rs, /home/daytona/workspace/fabro/lib/crates/fabro-mcp/src/connection_manager.rs, /home/daytona/workspace/fabro/lib/crates/fabro-types/src/run_event/agent.rs, /home/daytona/workspace/fabro/lib/crates/fabro-types/src/run_event/mod.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event/convert.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event/names.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/prompt.rs + + +# Simplify: Code Review and Cleanup + +Review changes vs. origin for reuse, quality, and efficiency. Fix any issues found. + +## Phase 1: Identify Changes + +Run git diff (or git diff HEAD if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation. + +## Phase 2: Launch Three Review Agents in Parallel + +Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context. + +### Agent 1: Code Reuse Review + +For each change: + +1. Search for existing utilities and helpers that could replace newly written code. Use Grep to find similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones. +2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead. +3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates. + +Note: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it. + +### Agent 2: Code Quality Review + +Review the same changes for hacky patterns: + +1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls +2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones +3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction +4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries +5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase + +Note: This is a greenfield app, so be aggressive in optimizing quality. + +### Agent 3: Efficiency Review + +Review the same changes for efficiency: + +1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns +2. Missed concurrency: independent operations run sequentially when they could run in parallel +3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths +4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error +5. Memory: unbounded data structures, missing cleanup, event listener leaks +6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one + +## Phase 3: Fix Issues + +Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it. + +When done, briefly summarize what was fixed (or confirm the code was already clean). \ No newline at end of file diff --git a/stages/006-simplify_opus@1/provider_used.json b/stages/006-simplify_opus@1/provider_used.json new file mode 100644 index 000000000..672cc50e5 --- /dev/null +++ b/stages/006-simplify_opus@1/provider_used.json @@ -0,0 +1,5 @@ +{ + "mode": "agent", + "provider": "anthropic", + "model": "claude-opus-4-7" +} \ No newline at end of file diff --git a/stages/006-simplify_opus@1/response.md b/stages/006-simplify_opus@1/response.md new file mode 100644 index 000000000..219082068 --- /dev/null +++ b/stages/006-simplify_opus@1/response.md @@ -0,0 +1,29 @@ +All checks green. Here's the summary: + +## Summary + +Three review agents flagged overlapping issues. I applied the high-value cleanups and skipped low-value or out-of-scope ones. + +### Fixed +1. **Eliminated 4 duplicate types** between `fabro-agent` and `fabro-types` (`McpToolSummary`, `MemoryFileSummary`, `SkillSummary`, `SkillActivationSource`). `AgentEvent` now embeds the `fabro-types` versions directly, mirroring the existing `TodoCreated/Updated/Deleted` pattern. Removed ~50 lines of field-by-field cloning in `convert.rs`. +2. **Deleted dead `SkillExpanded` variant** entirely — its emit site had already been replaced with `SkillActivated { source: Slash }`. Removed from `types.rs` (variant, streaming-noise classification, trace arm, test), `convert.rs` (panic-list), and `names.rs` (name mapping). +3. **Strongly typed `provider_profile`** as `fabro_model::AgentProfileKind` (Copy enum with existing `Display`) on both wire props and in-memory variants instead of `String`. Removed two `.to_string()` calls and per-event clones. +4. **Dropped redundant derived fields** from in-memory variants: + - `MemoryDocument.loaded_bytes` → always equals `content.len()`. + - `AgentEvent::McpServerReady.tool_count` → always equals `tools.len()`. + - `AgentEvent::MemoryLoaded.total_loaded_bytes` → sum of `files[].loaded_bytes`. + - All three are computed at the conversion boundary so the wire shape is preserved. + +### Skipped (with reason) +- **`build_system_prompt` signature change** (memory clone elimination): touches 6 files including public SDK reference docs and 4 profile impls for a single 32 KiB clone at session init. Not worth the API churn. +- **MCP tool N+1 fix**: one-shot pass at session startup, not a hot path. +- **`ToolContext` Option accretion**: out of scope for this PR; should be its own refactor when the next field is added. +- **Two-pass over memory in init**: micro-optimization, current code is clearer. + +### Verification +- `cargo check --workspace --all-targets` — clean +- `cargo nextest run -p fabro-agent -p fabro-workflow -p fabro-types -p fabro-mcp` — 1840 passed, 0 failed +- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` — clean +- `cargo +nightly-2026-04-14 fmt --all` — applied + +Net change: removed ~100 lines while preserving all behavior, wire format, and test coverage. \ No newline at end of file