From 8a0f18751a15ac48672792c7d89e78a1c173af68 Mon Sep 17 00:00:00 2001 From: Scott Stauffer Date: Mon, 6 Jul 2026 22:57:44 -0500 Subject: [PATCH 1/2] feat(context): apply Context: spec to init templates, drop EnableMemory - Judgment-independent roles (Reviewer/Auditor/Verifier-equivalents) in the audit, brownfield, devops, devteam, and research init templates now assemble from artifacts (Context:) instead of filtered shared history (ContextWindow), so they can't mistake an earlier agent's unverified claim for fact. - ContextAssembler now reports which declared sources resolved to no content (EmptySources), surfaced as context_strategy/declared_sources/ empty_sources on the context_assembly event and in the context window visualization, so a Context: spec pointing at a never-produced artifact is visible instead of silently empty. - EnableMemory has been a no-op since memory became always runtime- injected via ContextAssemblyPipeline; removed the field, its backward-compat merge logic, and all doc/example mentions in favor of KnowledgeWeight. --- config/examples/fuseraft-designer.yaml | 6 +- docs/configuration.md | 38 ++----- docs/context-management.md | 45 ++++++-- src/Cli/Commands/InitTemplates.Audit.cs | 11 ++ src/Cli/Commands/InitTemplates.Brownfield.cs | 6 +- src/Cli/Commands/InitTemplates.DevOps.cs | 6 + src/Cli/Commands/InitTemplates.DevTeam.cs | 3 + src/Cli/Commands/InitTemplates.Research.cs | 5 + src/Cli/Display/ContextWindowRenderer.cs | 2 + src/Cli/OrchestratorBuilder.cs | 5 +- src/Core/Models/Agents/AgentConfig.cs | 12 -- .../Models/Context/AgentContextAssembly.cs | 15 +++ .../Models/Context/ContextAssemblyMetrics.cs | 28 +++++ .../Orchestration/OrchestrationConfig.cs | 3 +- src/Orchestration/AgentOrchestrator.cs | 9 +- src/Orchestration/Context/ContextAssembler.cs | 8 +- .../Context/ContextAssemblyPipeline.cs | 12 +- src/Orchestration/GraphOrchestrator.cs | 3 + src/Orchestration/MagenticOrchestrator.cs | 3 + .../ContextAssemblerEmptySourceTests.cs | 104 ++++++++++++++++++ 20 files changed, 255 insertions(+), 69 deletions(-) create mode 100644 src/Core/Models/Context/AgentContextAssembly.cs create mode 100644 tests/FuseraftCli.Tests/ContextAssemblerEmptySourceTests.cs diff --git a/config/examples/fuseraft-designer.yaml b/config/examples/fuseraft-designer.yaml index bf5280bc..74d9fc58 100644 --- a/config/examples/fuseraft-designer.yaml +++ b/config/examples/fuseraft-designer.yaml @@ -43,12 +43,10 @@ Orchestration: ContextWindow.TextOnly (strip tool frames from history — useful for review agents), MaxToolCallsPerTurn, MaxInTurnContextTokens, MaxInTurnToolPairs (sliding-window cap — deterministic alternative to MaxInTurnContextTokens; recommended 8–16 for Developer/Tester/Operator), - EnableMemory, SubAgentModel, SubAgentPlugins, - RemoteAgent.Url (delegate to remote A2A endpoint — ignores Model/Plugins/FunctionChoice/Capabilities). + SubAgentModel, SubAgentPlugins, RemoteAgent.Url (delegate to remote A2A endpoint — ignores Model/Plugins/FunctionChoice/Capabilities). ROUTING: - - statemachine: States with Agent, Transitions (Signal, To, optional Contract for evidence gates). - Agents signal transitions with handoff(route_keyword: "SIGNAL") or plain keyword on its own line. + - statemachine: States with Agent, Transitions (Signal, To, optional Contract for evidence gates). Agents signal transitions with handoff(route_keyword: "SIGNAL") or plain keyword on its own line. - magentic: manager LLM selects participants dynamically each round. No routing keywords needed. - roundrobin / sequential: agents take turns in order. - keyword: routes on text patterns in responses. diff --git a/docs/configuration.md b/docs/configuration.md index 6b63aa19..5d3d1128 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -141,7 +141,6 @@ Each entry in `Agents` configures one participant in the group chat. | `MaxInTurnToolPairs` | int | `0` | no | Hard sliding-window cap (deterministic) on the number of tool call/result pairs kept in full within a turn. Before every inner LLM call, all but the most-recent N pairs are replaced with placeholders unconditionally — regardless of total token count. `0` means no limit. Recommended: 8–16 for high-volume action agents. | | `TrustScore` | number | `0.7` | no | Governance trust score (0.0–1.0) used to assign an execution ring. See [Governance](governance.md#execution-rings). | | `ContextWindow` | object | — | no | Filters the conversation history before it reaches this agent. See [ContextWindow](#contextwindow). | -| `EnableMemory` | bool | `false` | no | When `true`, persistent memories from `~/.fuseraft/memory/agents/{Name}/` are prepended to the agent's instructions at session start. See [Memory](#memory). | | `SubAgentModel` | string | — | no | Model ID override for the sub-agent spawned by the `SubAgent` plugin. Defaults to the parent agent's model when unset. Useful for running a cheaper model (e.g. Haiku) for `sub_agent_explore` / `sub_agent_locate` calls. | | `SubAgentPlugins` | array | — | no | Explicit list of plugin names to load into the sub-agent. When unset the sub-agent receives the default read-only set: FileSystem read, Search, Shell read, Git read. Unknown names raise an error at session startup. | | `SubAgentMaxToolCalls` | int | `0` | no | Maximum tool-call iterations for `sub_agent_explore`. `0` uses the built-in default of 20. `sub_agent_locate` always uses a hard cap of 5 regardless of this setting. | @@ -229,7 +228,6 @@ Agents: | int | inline is non-zero | | `TrustScore` | inline differs from `0.7` | | `FunctionChoice` | inline differs from `"auto"` | -| `EnableMemory` | either inline or file is `true` | This means: to inherit a field from the file, simply omit it in the inline config. To override, set it explicitly. @@ -278,7 +276,7 @@ Delegates an agent slot to a remote process that implements the [A2A protocol](h | `Url` | string | — | yes | Base URL of the remote A2A agent. Card is resolved from `{Url}/.well-known/agent.json`. | | `TimeoutSeconds` | int | `120` | no | HTTP timeout for card resolution and per-turn calls. | -**Fields that apply when `RemoteAgent` is set:** `Name`, `Instructions`, `TrustScore`, `ContextWindow`, `MaxToolCallsPerTurn`, `EnableMemory`. +**Fields that apply when `RemoteAgent` is set:** `Name`, `Instructions`, `TrustScore`, `ContextWindow`, `MaxToolCallsPerTurn`. **Fields that are ignored when `RemoteAgent` is set:** `Model`, `Plugins`, `FunctionChoice`, `Capabilities`, `SubAgentModel`, `SubAgentPlugins` — those are properties of the remote agent. @@ -325,28 +323,15 @@ Filters are applied in order: `TextOnly` / `ExcludeAgents` first, then `MaxTurnA ## Memory -When `EnableMemory: true` is set on an agent, fuseraft loads that agent's persistent memory store at session start and prepends a structured block to its instructions: - -```yaml -- Name: Developer - EnableMemory: true - Instructions: You are a software engineer... -``` +Every agent's persistent memory store is loaded and ranked by relevance before each turn, then +injected into its system prompt automatically by the context assembly pipeline — no per-agent +config is required. See [Context Management — Layer 2](context-management.md#layer-2-persistent-memory-pipeline-injected) +for ranking and injection format details. **How it works** Memories are stored as Markdown files with YAML frontmatter in `~/.fuseraft/memory/agents/{Name}/`. An index file (`MEMORY.md`) maintains a one-line-per-entry listing in injection order. -At session start, each memory entry is rendered into the agent's instructions as: - -``` -## Persistent Memory - -- [memory-name] (type): One-line description of the memory -``` - -When `EnableMemory: false` (the default), no memory is loaded and the directory is not read. - **Memory storage location** | Context | Path | @@ -371,7 +356,7 @@ When the session ends, the model is asked to extract new memories from the conve ## Pluggable memory provider -The `Memory` top-level key activates a live memory provider that runs pre- and post-turn hooks around every agent turn. Unlike the static `EnableMemory` flag (which loads once at session start), the pluggable provider fetches fresh context before each turn and can persist the full accumulated history after each turn. +The `Memory` top-level key activates a live memory provider that runs pre- and post-turn hooks around every agent turn. The provider fetches fresh context before each turn and can persist the full accumulated history after each turn. ### Providers @@ -416,15 +401,6 @@ Memory: | `TimeoutSeconds` | int | `10` | Per-request HTTP timeout. | | `SaveEveryNTurns` | int | `10` | Save only every Nth turn; 1 = every turn. | -### Relationship to `EnableMemory` - -`EnableMemory: true` on an agent and a top-level `Memory:` provider are independent: - -- `EnableMemory` loads memories once at agent creation time (synchronous, from disk). -- `Memory:` loads fresh context before each turn via the provider (async, per-turn). - -Both can be active simultaneously. The injected blocks are additive — the `EnableMemory` block is baked into the agent's static instructions; the `Memory:` block is prepended at turn time. - --- ## Selection strategy @@ -708,6 +684,8 @@ Each line is a JSON object: **`hitl_escalation` payload:** `{ message }` — the error message surfaced to the user when a validator fires 3 consecutive times and the session stalls. +**`context_assembly` payload:** `{ knowledge_retrieved, knowledge_included, memory_loaded, memory_included, artifacts, context_chars, system_prompt_chars, assembly_ms, context_strategy, declared_sources, empty_sources }` (sequential-agent turns add `context_chars_breakdown`, `tool_count`, `tool_schema_est_tokens`). `context_strategy` is `"artifact_spec"` when the agent's `Context:` block drove assembly or `"shared_history_fallback"` when it fell back to `ContextWindow`-filtered shared history — the field to alert on if you expect every Reviewer/Tester/Critic-style agent to be running isolated and want to catch one that silently isn't. `declared_sources` lists the `Context:` sources requested (empty under the fallback strategy); `empty_sources` is the subset that resolved to no content at assembly time — e.g. a `brief_field:` naming a field the Planner never wrote — distinguishing "the spec omitted a needed source" (visible by reading the config) from "the spec named a source that was never produced" (only visible at runtime, via this field). + **Omit** `Events` if you don't need the event stream. --- diff --git a/docs/context-management.md b/docs/context-management.md index cc33d3b4..86fae9df 100644 --- a/docs/context-management.md +++ b/docs/context-management.md @@ -34,10 +34,6 @@ types — `AgentOrchestrator` (sequential, parallel, verifier), `MagenticOrchest agents) all call `AssembleAsync` identically. Most layers are always-on; use `KnowledgeWeight` on an agent's config to tune retrieval depth. -> **Upgrading from `EnableMemory`:** `EnableMemory: true` is deprecated. Memory is now -> runtime-injected by the pipeline every turn and ranked by relevance to the current task. -> Remove `EnableMemory` from your agent configs — it will be ignored in a future release. - --- ## Automatic runtime injection @@ -121,9 +117,6 @@ directory are loaded. Directories without `.fuseraft/` fall back to all global m automatically at the end of each session and scoped to the working directory via `.fuseraft/memory/sessions/{session_id}/memory_refs.json`. Use `/memory` commands to inspect or delete them. -> **Deprecated:** `EnableMemory: true` on an agent config is no longer needed. Memory is now -> injected at runtime by the pipeline regardless of this flag. - See [Configuration — Memory](configuration.md#memory) for the full field reference. --- @@ -306,6 +299,30 @@ Transitions: When `Context:` is declared on an agent, the orchestrator assembles that agent's context from disk artifacts instead of filtering or replaying the shared transcript. The agent receives only the declared sources plus its own prior turns — no Planner analysis, no Developer tool traces, nothing from other agents. +> **Recommended for judgment-independent roles.** When every agent shares the same growing +> transcript (Layer 3), a downstream agent can't distinguish a verified fact from an earlier +> agent's unverified claim — the conversation itself becomes evidence, and claims compound +> into hallucinations several turns later. `Context:` spec is the fix: it drives assembly from +> durable artifacts (`brief.json`, `changes.json`, the evidence graph) instead of replayed +> chat, so an agent's information diet is exactly what someone deliberately packaged for it. +> Treat it as the default for roles that render an independent verdict — Reviewer, Tester, +> Critic, Auditor — and reserve full shared-history replay (`ContextWindow`, below) for +> collaborative/continuity roles (Planner, Developer mid-phase) and for rapid prototyping, +> where you don't yet know which artifacts a new agent needs. The `swe`, `greenfield`, +> `audit`, `research`, `brownfield`, and `devops` templates generated by `fuseraft init` +> apply `Context:` to their Reviewer/Tester/Critic/Auditor/Verifier-equivalent agents by +> default — use those as a starting point rather than designing a source list from scratch. +> +> **Exception:** an agent whose job is specifically to catch a mismatch between what other +> agents *claimed* and what the change log / execution state actually shows (e.g. an +> evidence-auditor `Verifier` that cross-checks "claimed success without evidence" patterns) +> needs to see the claims to audit them — isolating it via `Context:` would remove the very +> signal it exists to check. The `swe` template's `Verifier` is intentionally left on shared +> history for this reason. +> +> A declared source resolving to no content is itself a signal worth watching, not just a +> silent gap — see `empty_sources` in the [`context_assembly` event payload](configuration.md#events) below. + ```yaml Agents: - Name: Tester @@ -830,7 +847,15 @@ Compaction: TokenBudget: 60000 ``` -**For a downstream agent (Reviewer, Tester) that needs less history:** use `ContextWindow`. +**For a judgment-independent agent (Reviewer, Tester, Critic, Auditor) in production:** use +`Context:` spec (Layer 3a) — see below. It's the recommended default for these roles because +it assembles from durable artifacts rather than replayed chat, so the agent can't mistake an +earlier agent's unverified claim for a fact. The remaining examples below (`ContextWindow` +filtering of shared history) are the lighter/compatibility path — reach for them when you're +prototyping a new pipeline and haven't yet worked out which artifacts a role needs, or for +roles that are meant to see prior claims (see the exception noted in Layer 3a above). + +**For a downstream agent still on shared history that just needs less of it:** use `ContextWindow`. ```yaml Agents: @@ -840,8 +865,8 @@ Agents: MaxTurnAge: 3 ``` -**For an agent that should know nothing about earlier phases:** combine `ExcludeAgents` with -`MaxTailMessages` so it only sees the final handoff. +**For an agent that should know nothing about earlier phases but is still on shared history:** +combine `ExcludeAgents` with `MaxTailMessages` so it only sees the final handoff. ```yaml Agents: diff --git a/src/Cli/Commands/InitTemplates.Audit.cs b/src/Cli/Commands/InitTemplates.Audit.cs index a2d9524c..b239e4be 100644 --- a/src/Cli/Commands/InitTemplates.Audit.cs +++ b/src/Cli/Commands/InitTemplates.Audit.cs @@ -90,6 +90,11 @@ You are read-only with respect to this project's own files — you have no Capabilities: FileSystem: [read] FunctionChoice: required + Context: + - Source: session_context + - Source: file:.fuseraft/artifacts/audit-findings.json + MaxChars: 6000 + - Source: own_history:2 {AgentFileOptions} """; @@ -158,6 +163,12 @@ so the Prioritizer can update the plan and the Developer can retry. Capabilities: FileSystem: [read] FunctionChoice: required + Context: + - Source: session_context + - Source: file:.fuseraft/artifacts/remediation-plan.json + MaxChars: 6000 + - Source: changes_recent:5 + - Source: own_history:2 {AgentFileOptions} """; diff --git a/src/Cli/Commands/InitTemplates.Brownfield.cs b/src/Cli/Commands/InitTemplates.Brownfield.cs index c5e67402..d5f19be1 100644 --- a/src/Cli/Commands/InitTemplates.Brownfield.cs +++ b/src/Cli/Commands/InitTemplates.Brownfield.cs @@ -205,8 +205,10 @@ and evidence before your routing keyword. Capabilities: FileSystem: [read] FunctionChoice: auto - ContextWindow: - TextOnly: true + Context: + - Source: session_context + - Source: changes_recent:3 + - Source: own_history:2 {AgentFileOptions} """; diff --git a/src/Cli/Commands/InitTemplates.DevOps.cs b/src/Cli/Commands/InitTemplates.DevOps.cs index 26275aa6..6d8c9be3 100644 --- a/src/Cli/Commands/InitTemplates.DevOps.cs +++ b/src/Cli/Commands/InitTemplates.DevOps.cs @@ -116,6 +116,12 @@ can run the rollback steps. Capabilities: FileSystem: [read] FunctionChoice: required + Context: + - Source: session_context + - Source: file:.fuseraft/artifacts/ops-plan.yaml + MaxChars: 4000 + - Source: changes_recent:3 + - Source: own_history:2 {AgentFileOptions} """; diff --git a/src/Cli/Commands/InitTemplates.DevTeam.cs b/src/Cli/Commands/InitTemplates.DevTeam.cs index 110f4f98..6ac74ad6 100644 --- a/src/Cli/Commands/InitTemplates.DevTeam.cs +++ b/src/Cli/Commands/InitTemplates.DevTeam.cs @@ -275,6 +275,9 @@ You are read-only with respect to this project's own files — you have no Capabilities: FileSystem: [read] FunctionChoice: required + Context: + - Source: session_context + - Source: own_history:2 {AgentFileOptions} """; diff --git a/src/Cli/Commands/InitTemplates.Research.cs b/src/Cli/Commands/InitTemplates.Research.cs index f8a4b4ab..87501f1f 100644 --- a/src/Cli/Commands/InitTemplates.Research.cs +++ b/src/Cli/Commands/InitTemplates.Research.cs @@ -93,6 +93,11 @@ You are read-only with respect to this project's own files — you have no Capabilities: FileSystem: [read] FunctionChoice: required + Context: + - Source: session_context + - Source: file:.fuseraft/docs/research-findings.md + MaxChars: 8000 + - Source: own_history:2 {AgentFileOptions} """; diff --git a/src/Cli/Display/ContextWindowRenderer.cs b/src/Cli/Display/ContextWindowRenderer.cs index c2145e51..e0c5bcad 100644 --- a/src/Cli/Display/ContextWindowRenderer.cs +++ b/src/Cli/Display/ContextWindowRenderer.cs @@ -342,6 +342,8 @@ function buildEvtAnnotations(useStringX) { if (ca.context_chars != null) lines.push(' context: ' + ca.context_chars.toLocaleString() + ' chars'); if (ca.tool_count != null) lines.push(' tools: ' + ca.tool_count); if (ca.assembly_ms != null) lines.push(' assembly: '+ ca.assembly_ms + ' ms'); + if (ca.context_strategy != null) lines.push(' strategy: ' + ca.context_strategy); + if (ca.empty_sources && ca.empty_sources.length) lines.push(' ⚠ empty sources: ' + ca.empty_sources.join(', ')); } return lines; }, diff --git a/src/Cli/OrchestratorBuilder.cs b/src/Cli/OrchestratorBuilder.cs index 9ba3632b..355ded2d 100644 --- a/src/Cli/OrchestratorBuilder.cs +++ b/src/Cli/OrchestratorBuilder.cs @@ -2004,13 +2004,10 @@ baseConfig with FunctionChoice = inline.FunctionChoice != "auto" ? inline.FunctionChoice : baseConfig.FunctionChoice, TrustScore = inline.TrustScore != 0.7 ? inline.TrustScore : baseConfig.TrustScore, ContextWindow = inline.ContextWindow ?? baseConfig.ContextWindow, - Capabilities = inline.Capabilities.Count > 0 ? inline.Capabilities : baseConfig.Capabilities, + Capabilities = inline.Capabilities.Count > 0 ? inline.Capabilities : baseConfig.Capabilities, MaxToolCallsPerTurn = inline.MaxToolCallsPerTurn != 0 ? inline.MaxToolCallsPerTurn : baseConfig.MaxToolCallsPerTurn, MaxInTurnContextTokens = inline.MaxInTurnContextTokens != 0 ? inline.MaxInTurnContextTokens : baseConfig.MaxInTurnContextTokens, MaxInTurnToolPairs = inline.MaxInTurnToolPairs != 0 ? inline.MaxInTurnToolPairs : baseConfig.MaxInTurnToolPairs, -#pragma warning disable CS0618 // EnableMemory is obsolete but still merged for backward-compat configs - EnableMemory = inline.EnableMemory || baseConfig.EnableMemory, -#pragma warning restore CS0618 SubAgentModel = inline.SubAgentModel ?? baseConfig.SubAgentModel, SubAgentPlugins = inline.SubAgentPlugins ?? baseConfig.SubAgentPlugins, RemoteAgent = inline.RemoteAgent ?? baseConfig.RemoteAgent, diff --git a/src/Core/Models/Agents/AgentConfig.cs b/src/Core/Models/Agents/AgentConfig.cs index 035245da..7098c580 100644 --- a/src/Core/Models/Agents/AgentConfig.cs +++ b/src/Core/Models/Agents/AgentConfig.cs @@ -206,18 +206,6 @@ public record AgentConfig /// public KnowledgeWeight KnowledgeWeight { get; init; } = KnowledgeWeight.Default; - /// - /// Superseded by . Memory is now always injected at - /// runtime through - /// rather than baked into agent instructions at construction time. - /// This property is kept for configuration compatibility but has no effect when - /// ContextAssemblyPipeline is active (which is always the case for - /// ). - /// - [Obsolete("Memory is now always runtime-injected through ContextAssemblyPipeline. " + - "Set KnowledgeWeight instead to control retrieval breadth.")] - public bool EnableMemory { get; init; } = false; - /// /// Optional model override for the sub-agent spawned by the SubAgent plugin. /// When set, the sub-agent uses this model instead of inheriting the parent agent's model. diff --git a/src/Core/Models/Context/AgentContextAssembly.cs b/src/Core/Models/Context/AgentContextAssembly.cs new file mode 100644 index 00000000..f048e80b --- /dev/null +++ b/src/Core/Models/Context/AgentContextAssembly.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.AI; + +namespace fuseraft.Core.Models.Context; + +/// +/// Result of . +/// +/// Ready-to-use message list replacing shared-history replay. +/// +/// Declared artifact source specs (excluding own_history) that resolved to no content — +/// e.g. a brief_field: naming a field absent from brief.json. +/// +public sealed record AgentContextAssembly( + IReadOnlyList Messages, + IReadOnlyList EmptySources); diff --git a/src/Core/Models/Context/ContextAssemblyMetrics.cs b/src/Core/Models/Context/ContextAssemblyMetrics.cs index 1e68e899..e188453c 100644 --- a/src/Core/Models/Context/ContextAssemblyMetrics.cs +++ b/src/Core/Models/Context/ContextAssemblyMetrics.cs @@ -70,5 +70,33 @@ public sealed record ContextAssemblyMetrics /// Wall-clock time spent inside AssembleAsync. public TimeSpan AssemblyDuration { get; init; } + /// + /// Which path built this agent's context: when a + /// Context: block drove assembly, + /// when no spec was declared and the shared transcript was filtered instead. + /// + public string ContextStrategy { get; init; } = Strategies.SharedHistoryFallback; + + /// + /// Source specs declared on the agent's Context: block (e.g. "brief_field:test_targets"). + /// Empty when is . + /// + public IReadOnlyList DeclaredSources { get; init; } = []; + + /// + /// Subset of that resolved to no content at assembly time — + /// e.g. a brief_field: naming a field absent from brief.json. Signals a + /// Context: spec that references an artifact which was never produced, as opposed + /// to a spec that simply omits a source the agent needed. + /// + public IReadOnlyList EmptySources { get; init; } = []; + + /// String constants for . + public static class Strategies + { + public const string ArtifactSpec = "artifact_spec"; + public const string SharedHistoryFallback = "shared_history_fallback"; + } + public static readonly ContextAssemblyMetrics Empty = new(); } diff --git a/src/Core/Models/Orchestration/OrchestrationConfig.cs b/src/Core/Models/Orchestration/OrchestrationConfig.cs index 15b8613a..4b2b17d8 100644 --- a/src/Core/Models/Orchestration/OrchestrationConfig.cs +++ b/src/Core/Models/Orchestration/OrchestrationConfig.cs @@ -259,8 +259,7 @@ public record OrchestrationConfig /// and injected into the orchestrator's pre- and post-turn hooks: memory is loaded /// before each agent turn and appended to the agent's system instructions; the full /// turn history is offered to the provider for persistence after each turn. - /// Null (default) disables orchestration-level memory (agents that set - /// EnableMemory: true still use the static file-backed store at creation time). + /// Null (default) disables orchestration-level memory. /// public MemoryConfig? Memory { get; init; } } diff --git a/src/Orchestration/AgentOrchestrator.cs b/src/Orchestration/AgentOrchestrator.cs index ab8d1948..8152db5a 100644 --- a/src/Orchestration/AgentOrchestrator.cs +++ b/src/Orchestration/AgentOrchestrator.cs @@ -750,6 +750,11 @@ private static Task EmitContextAssemblyAsync( context_chars = metrics.TotalContextChars, system_prompt_chars = metrics.SystemPromptChars, assembly_ms = (int)metrics.AssemblyDuration.TotalMilliseconds, + // Which path built this context, and — for Context: spec agents — which + // declared sources resolved vs. which came back empty (missing artifact). + context_strategy = metrics.ContextStrategy, + declared_sources = metrics.DeclaredSources, + empty_sources = metrics.EmptySources, // Per-source char breakdown — shows which source dominates startup context. context_chars_breakdown = new { @@ -898,8 +903,8 @@ await EmitContextAssemblyAsync(eventEmitter, assembled.Metrics, turn, IReadOnlyList filtered; if (agentCfg?.Context is { Count: > 0 } agentContextSources && contextAssembler is not null) { - filtered = await contextAssembler.AssembleForAgentAsync( - agentName, task, agentContextSources, history, cancellationToken); + filtered = (await contextAssembler.AssembleForAgentAsync( + agentName, task, agentContextSources, history, cancellationToken)).Messages; } else { diff --git a/src/Orchestration/Context/ContextAssembler.cs b/src/Orchestration/Context/ContextAssembler.cs index aa538992..cc052f39 100644 --- a/src/Orchestration/Context/ContextAssembler.cs +++ b/src/Orchestration/Context/ContextAssembler.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.AI; using fuseraft.Core; using fuseraft.Core.Models; +using fuseraft.Core.Models.Context; using fuseraft.Infrastructure; namespace fuseraft.Orchestration.Context; @@ -141,7 +142,7 @@ public ContextAssembler( /// (session context, change log, brief fields, files). /// /// - public async Task> AssembleForAgentAsync( + public async Task AssembleForAgentAsync( string agentName, string task, IReadOnlyList sources, @@ -149,6 +150,7 @@ public async Task> AssembleForAgentAsync( CancellationToken ct = default) { var result = new List(); + var emptySources = new List(); // 1. Task message — the agent always needs to know what it's working on. result.Add(new ChatMessage(ChatRole.User, task)); @@ -182,6 +184,8 @@ public async Task> AssembleForAgentAsync( var content = await ResolveArtifactAsync(src, ct); if (!string.IsNullOrWhiteSpace(content)) sections.Add((src.Label ?? DefaultLabel(src.Source), content.Trim())); + else + emptySources.Add(src.Source); } if (sections.Count > 0) @@ -214,7 +218,7 @@ public async Task> AssembleForAgentAsync( result.Add(new ChatMessage(ChatRole.User, $"[Task Reminder]\n\n{preview}")); } - return result; + return new AgentContextAssembly(result, emptySources); } // ── Shared source resolution ───────────────────────────────────────────── diff --git a/src/Orchestration/Context/ContextAssemblyPipeline.cs b/src/Orchestration/Context/ContextAssemblyPipeline.cs index b7617b5c..3c4f79fa 100644 --- a/src/Orchestration/Context/ContextAssemblyPipeline.cs +++ b/src/Orchestration/Context/ContextAssemblyPipeline.cs @@ -121,14 +121,21 @@ public async Task AssembleAsync( IReadOnlyList historyMessages = []; // used for breakdown stats below int sessionContextChars = 0; int historyChars = 0; + var contextStrategy = ContextAssemblyMetrics.Strategies.SharedHistoryFallback; + IReadOnlyList declaredSources = []; + IReadOnlyList emptySources = []; if (agentCfg?.Context is { Count: > 0 } contextSources && _contextAssembler is not null) { - baseMessages = await _contextAssembler.AssembleForAgentAsync( + var assembled = await _contextAssembler.AssembleForAgentAsync( agentName, task, contextSources, history as IList ?? new List(history), ct); + baseMessages = assembled.Messages; historyChars = baseMessages.Sum(m => m.Text?.Length ?? 0); historyMessages = baseMessages; + contextStrategy = ContextAssemblyMetrics.Strategies.ArtifactSpec; + declaredSources = contextSources.Select(s => s.Source).ToList(); + emptySources = assembled.EmptySources; } else { @@ -215,6 +222,9 @@ public async Task AssembleAsync( HistoryToolCount = historyToolCount, HistoryHasCompactionSummary = historyHasCompaction, AssemblyDuration = sw.Elapsed, + ContextStrategy = contextStrategy, + DeclaredSources = declaredSources, + EmptySources = emptySources, }; _logger?.LogDebug( diff --git a/src/Orchestration/GraphOrchestrator.cs b/src/Orchestration/GraphOrchestrator.cs index 9af8dba8..c0df501d 100644 --- a/src/Orchestration/GraphOrchestrator.cs +++ b/src/Orchestration/GraphOrchestrator.cs @@ -1603,6 +1603,9 @@ private static Task EmitContextAssemblyAsync( context_chars = metrics.TotalContextChars, system_prompt_chars = metrics.SystemPromptChars, assembly_ms = (int)metrics.AssemblyDuration.TotalMilliseconds, + context_strategy = metrics.ContextStrategy, + declared_sources = metrics.DeclaredSources, + empty_sources = metrics.EmptySources, }); private static async ValueTask PersistCorrectionsAsync( diff --git a/src/Orchestration/MagenticOrchestrator.cs b/src/Orchestration/MagenticOrchestrator.cs index 3482839e..9350a534 100644 --- a/src/Orchestration/MagenticOrchestrator.cs +++ b/src/Orchestration/MagenticOrchestrator.cs @@ -966,6 +966,9 @@ private static Task EmitContextAssemblyAsync( context_chars = metrics.TotalContextChars, system_prompt_chars = metrics.SystemPromptChars, assembly_ms = (int)metrics.AssemblyDuration.TotalMilliseconds, + context_strategy = metrics.ContextStrategy, + declared_sources = metrics.DeclaredSources, + empty_sources = metrics.EmptySources, }); // Manager invocation diff --git a/tests/FuseraftCli.Tests/ContextAssemblerEmptySourceTests.cs b/tests/FuseraftCli.Tests/ContextAssemblerEmptySourceTests.cs new file mode 100644 index 00000000..cd13a25e --- /dev/null +++ b/tests/FuseraftCli.Tests/ContextAssemblerEmptySourceTests.cs @@ -0,0 +1,104 @@ +using fuseraft.Core.Models.Orchestration; +using Microsoft.Extensions.AI; +using fuseraft.Orchestration.Context; + +namespace FuseraftCli.Tests; + +/// +/// Unit tests for 's empty-source +/// reporting — the signal that lets the context_assembly event distinguish "the +/// agent's Context: spec omitted a needed source" from "the declared source referenced +/// an artifact that was never produced" (docs/context-management.md, Layer 3a). +/// +public sealed class ContextAssemblerEmptySourceTests +{ + private static ContextSource Src(string source) => new() { Source = source }; + + [Fact] + public async Task Brief_field_present_in_brief_json_is_not_reported_empty() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var briefPath = Path.Combine(dir.FullName, "brief.json"); + await File.WriteAllTextAsync(briefPath, """{ "acceptance_criteria": "all tests pass" }"""); + + var assembler = new ContextAssembler(briefPath: briefPath); + var result = await assembler.AssembleForAgentAsync( + "Reviewer", "review the change", + [Src("brief_field:acceptance_criteria")], + new List()); + + Assert.Empty(result.EmptySources); + } + finally { dir.Delete(recursive: true); } + } + + [Fact] + public async Task Brief_field_missing_from_brief_json_is_reported_empty() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var briefPath = Path.Combine(dir.FullName, "brief.json"); + await File.WriteAllTextAsync(briefPath, """{ "acceptance_criteria": "all tests pass" }"""); + + var assembler = new ContextAssembler(briefPath: briefPath); + var result = await assembler.AssembleForAgentAsync( + "Reviewer", "review the change", + [Src("brief_field:test_targets")], + new List()); + + Assert.Equal(["brief_field:test_targets"], result.EmptySources); + } + finally { dir.Delete(recursive: true); } + } + + [Fact] + public async Task Missing_brief_file_reports_all_brief_field_sources_empty() + { + var assembler = new ContextAssembler(briefPath: "/nonexistent/brief.json"); + var result = await assembler.AssembleForAgentAsync( + "Reviewer", "review the change", + [Src("brief_field:acceptance_criteria"), Src("brief_field:test_targets")], + new List()); + + Assert.Equal( + new[] { "brief_field:acceptance_criteria", "brief_field:test_targets" }, + result.EmptySources); + } + + [Fact] + public async Task Own_history_source_is_never_reported_as_an_empty_artifact() + { + var assembler = new ContextAssembler(briefPath: "/nonexistent/brief.json"); + var history = new List { new(ChatRole.Assistant, "prior turn") { AuthorName = "Reviewer" } }; + + var result = await assembler.AssembleForAgentAsync( + "Reviewer", "review the change", + [Src("own_history:3")], + history); + + Assert.Empty(result.EmptySources); + } + + [Fact] + public async Task Resolved_source_content_still_appears_in_assembled_messages() + { + var dir = Directory.CreateTempSubdirectory(); + try + { + var briefPath = Path.Combine(dir.FullName, "brief.json"); + await File.WriteAllTextAsync(briefPath, """{ "acceptance_criteria": "all tests pass" }"""); + + var assembler = new ContextAssembler(briefPath: briefPath); + var result = await assembler.AssembleForAgentAsync( + "Reviewer", "review the change", + [Src("brief_field:acceptance_criteria")], + new List()); + + Assert.Contains(result.Messages, m => m.Text?.Contains("all tests pass") == true); + } + finally { dir.Delete(recursive: true); } + } +} From 004fea0efc929e55513cee4ab36acb0df678ff64 Mon Sep 17 00:00:00 2001 From: Scott Stauffer Date: Mon, 6 Jul 2026 23:04:35 -0500 Subject: [PATCH 2/2] chore(repl): remove experimental repl-next command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReplNextCommand/ReplNextTurn duplicated ReplCommand's setup and turn loop behind a hidden repl-next command and the FUSERAFT_REPL_NEXT env var, with no callers or docs depending on it; dropped both files and their wiring in Program.cs (DI registration, default-entrypoint switch, command registration). - Dropped stray "and cost" / "estimated cost" mentions from the VS Code sessions panel description (README.md) and the AgentMessage.Usage doc comment — the per-turn cost estimate they referred to is not surfaced there. --- README.md | 2 +- src/Cli/Commands/Repl/ReplNextCommand.cs | 505 ----------------------- src/Cli/Commands/Repl/ReplNextTurn.cs | 195 --------- src/Core/Models/Agents/AgentMessage.cs | 4 +- src/Program.cs | 11 +- 5 files changed, 3 insertions(+), 714 deletions(-) delete mode 100644 src/Cli/Commands/Repl/ReplNextCommand.cs delete mode 100644 src/Cli/Commands/Repl/ReplNextTurn.cs diff --git a/README.md b/README.md index b91b5b04..77b67df1 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ The [fuseraft VS Code extension](https://github.com/fuseraft/fuseraft-vscode) br **Activity bar panel** — four persistent views: - **Run Task** — compose a task, pick a config, set flags (`--hitl`, `--tools`, `--verbose`, `--devui`), and launch. Each task opens in its own named terminal; multiple tasks can run simultaneously. -- **Sessions** — lists sessions scoped to your workspace with status, age, and task preview. Click to resume; preview icon opens a formatted transcript with per-turn token usage and cost. +- **Sessions** — lists sessions scoped to your workspace with status, age, and task preview. Click to resume; preview icon opens a formatted transcript with per-turn token usage. - **Configs** — auto-discovers every fuseraft config in your workspace. Click to open, or hit **+** to run the Initialize Config wizard. - **Context** — manages reference material agents can access during sessions. Import files or folders; they're stored in `.fuseraft/context/` and available to any session in the workspace. diff --git a/src/Cli/Commands/Repl/ReplNextCommand.cs b/src/Cli/Commands/Repl/ReplNextCommand.cs deleted file mode 100644 index 0f9d3957..00000000 --- a/src/Cli/Commands/Repl/ReplNextCommand.cs +++ /dev/null @@ -1,505 +0,0 @@ -using System.ComponentModel; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Spectre.Console; -using Spectre.Console.Cli; -using fuseraft.Cli.Commands; -using fuseraft.Cli.Display; -using fuseraft.Core; -using fuseraft.Core.Models; -using fuseraft.Infrastructure; -using fuseraft.Infrastructure.KeyStore; -using fuseraft.Infrastructure.Plugins; -using fuseraft.Orchestration; - -namespace fuseraft.Cli.Commands.Repl; - -/// -/// Experimental next-gen REPL. Identical setup to but -/// delegates to for the terminal UI. -/// Enable as the default entry-point via FUSERAFT_REPL_NEXT=1, or invoke -/// directly with fuseraft repl-next. -/// -public sealed class ReplNextCommand(ILoggerFactory loggerFactory) : AsyncCommand -{ - private static readonly (string EnvVar, string ModelId)[] AutoDetectOrder = - [ - ("ANTHROPIC_API_KEY", "claude-sonnet-4-6"), - ("OPENAI_API_KEY", "gpt-4o-mini"), - ("XAI_API_KEY", "grok-4.3"), - ("GOOGLE_AI_API_KEY", "gemini-2.0-flash"), - ("MISTRAL_API_KEY", "mistral-small-latest"), - ("DEEPSEEK_API_KEY", "deepseek-chat"), - ]; - - protected override async Task ExecuteAsync( - CommandContext context, ReplSettings settings, CancellationToken cancellationToken) - { - bool jsonMode = OrchestratorBuilder.VsCodeMode && Console.IsInputRedirected; - - var keyStore = ApiKeyStoreFactory.Create(); - var (userCfg, legacyKey) = UserConfigStore.Load(); - - if (OrchestratorBuilder.VsCodeMode) - { - if (userCfg is not null) - { - var envKey = Environment.GetEnvironmentVariable("FUSERAFT_API_KEY"); - userCfg.ApiKey = !string.IsNullOrEmpty(envKey) - ? envKey - : !string.IsNullOrEmpty(legacyKey) - ? legacyKey - : await keyStore.RetrieveAsync() ?? string.Empty; - } - } - else if (!string.IsNullOrEmpty(legacyKey)) - { - userCfg!.ApiKey = legacyKey; - if (await KeyStorePersistence.TryStoreAsync(keyStore, legacyKey)) - AnsiConsole.MarkupLine($"[dim]API key migrated to {Markup.Escape(keyStore.StoreName)}.[/]"); - UserConfigStore.Save(userCfg); - } - else if (userCfg is not null) - { - userCfg.ApiKey = await keyStore.RetrieveAsync() ?? string.Empty; - } - - var modelId = ResolveModelId(settings, userCfg); - - bool pendingSave = false; - bool keyStored = true; - if (userCfg == null || !userCfg.IsConfigured) - { - if (jsonMode) - { - ReplJsonBridge.Emit(new { type = "error", text = "fuseraft is not configured. Run 'fuseraft setup' or use the fuseraft: Configure fuseraft command in VS Code." }); - return 1; - } - AnsiConsole.MarkupLine($"[dim]No configuration found at[/] [bold]{Markup.Escape(UserConfigStore.ConfigPath)}[/]"); - AnsiConsole.WriteLine(); - string? wizardKey; - bool selectedFromList; - (userCfg, wizardKey, selectedFromList) = await ReplFactory.RunSetupWizardAsync(modelId, userCfg); - if (userCfg is null || wizardKey is null) return 1; - keyStored = string.IsNullOrEmpty(wizardKey) || await KeyStorePersistence.TryStoreAsync(keyStore, wizardKey); - userCfg.ApiKey = wizardKey; - modelId = userCfg.ModelId; - if (selectedFromList) - { - UserConfigStore.Save(userCfg); - AnsiConsole.MarkupLine($"[dim]Settings saved to[/] [bold]{Markup.Escape(UserConfigStore.ConfigPath)}[/]"); - if (keyStored) - AnsiConsole.MarkupLine($"[dim]API key stored in[/] [bold]{Markup.Escape(keyStore.StoreName)}[/]"); - } - else - { - pendingSave = true; - } - } - - if (string.IsNullOrEmpty(modelId)) - { - if (jsonMode) - ReplJsonBridge.Emit(new { type = "error", text = "No model specified and no supported API key found. Run fuseraft setup to configure." }); - else - { - AnsiConsole.MarkupLine("[red]✗ No model specified and no supported API key found.[/]"); - AnsiConsole.MarkupLine("[dim]Run[/] [bold]fuseraft repl[/] [dim]to configure, or pass[/] [bold]--model[/]."); - } - return 1; - } - - var modelConfig = ReplFactory.BuildModelConfig(modelId, userCfg); - using var factory = new ChatClientFactory(); - - var toolsByCategory = new Dictionary>(StringComparer.OrdinalIgnoreCase); - using ShellPlugin? shellPlugin = settings.NoTools ? null : new ShellPlugin(shellPolicy: TryLoadDefaultShellPolicy()); - SubAgentPlugin? subAgent = null; - SkillsPlugin? skillsPlugin = null; - string? skillsCatalog = null; - List? explorerTools = null; - if (!settings.NoTools) - { - toolsByCategory["FileSystem"] = PluginRegistry.GetFunctionsFromObject(new FileSystemPlugin()).ToList(); - toolsByCategory["Shell"] = PluginRegistry.GetFunctionsFromObject(shellPlugin!).ToList(); - toolsByCategory["Search"] = PluginRegistry.GetFunctionsFromObject(new SearchPlugin()).ToList(); - toolsByCategory["Git"] = PluginRegistry.GetFunctionsFromObject(new GitPlugin()).ToList(); - toolsByCategory["Http"] = PluginRegistry.GetFunctionsFromObject(new HttpPlugin()).ToList(); - - var fsReadOps = new HashSet(StringComparer.OrdinalIgnoreCase) - { "read_file", "list_files", "grep_file", "get_file_summary", "get_file_info" }; - var shellReadOps = new HashSet(StringComparer.OrdinalIgnoreCase) - { "shell_run", "shell_get_env", "shell_which", "shell_get_working_directory" }; - var gitReadOps = new HashSet(StringComparer.OrdinalIgnoreCase) - { "git_status", "git_diff", "git_log", "git_show", "git_branch_list", "git_stash_list" }; - explorerTools = toolsByCategory["FileSystem"].Where(f => fsReadOps.Contains(f.Name)) - .Concat(toolsByCategory["Search"]) - .Concat(toolsByCategory["Shell"].Where(f => shellReadOps.Contains(f.Name))) - .Concat(toolsByCategory["Git"].Where(f => gitReadOps.Contains(f.Name))) - .ToList(); - - (skillsPlugin, skillsCatalog) = ReplSkillsLoader.BuildSkills(); - if (skillsPlugin is not null) - toolsByCategory["Skills"] = PluginRegistry.GetFunctionsFromObject(skillsPlugin).ToList(); - } - - var initialTools = toolsByCategory.Values.SelectMany(v => v).ToList(); - IChatClient client; - try - { - client = ReplFactory.BuildClient(modelConfig, factory, initialTools.Count > 0); - } - catch (Exception ex) - { - AnsiConsole.MarkupLine($"[red]✗ Could not create chat client:[/] {Markup.Escape(ex.Message)}"); - return 1; - } - - var cwd = Directory.GetCurrentDirectory(); - var eventsPath = FuseraftPaths.ExpandProjectPaths(FuseraftPaths.LocalReplEventsLog, FuseraftPaths.ProjectSlug(cwd)); - - ReplSessionSnapshot? snapshot = null; - if (!string.IsNullOrWhiteSpace(settings.Resume)) - { - snapshot = await ReplSessionSnapshot.LoadAsync(settings.Resume.Trim()); - if (snapshot is null) - { - AnsiConsole.MarkupLine($"[red]✗ No saved session found with ID '[/][bold]{Markup.Escape(settings.Resume.Trim())}[/][red]'.[/]"); - AnsiConsole.MarkupLine("[dim] Use /sessions inside the REPL to list resumable sessions.[/]"); - return 1; - } - } - - var sessionId = snapshot?.SessionId ?? StringHelpers.NewSessionId(); - var startedAt = snapshot?.StartedAt ?? DateTime.UtcNow; - - ReplSessionPlugin? replSessionPlugin = null; - List activePlugins = []; - if (!settings.NoTools) - { - replSessionPlugin = new ReplSessionPlugin(sessionId, startedAt, modelId, cwd); - toolsByCategory["Session"] = PluginRegistry.GetFunctionsFromObject(replSessionPlugin).ToList(); - - var enabled = settings.EnabledPlugins; - var slug = FuseraftPaths.ProjectSlug(cwd); - - if (enabled.Contains("Changes")) - { - var p = new ChangesPlugin(FuseraftPaths.ExpandProjectPaths(FuseraftPaths.LocalChanges, slug)); - toolsByCategory["Changes"] = PluginRegistry.GetFunctionsFromObject(p).ToList(); - activePlugins.Add(p); - } - - if (enabled.Contains("Chatroom")) - { - var p = new ChatroomPlugin("repl", FuseraftPaths.ExpandSessionPaths(FuseraftPaths.LocalChatroom, sessionId, slug)); - toolsByCategory["Chatroom"] = PluginRegistry.GetFunctionsFromObject(p).ToList(); - activePlugins.Add(p); - } - - if (enabled.Contains("SessionContext")) - { - var p = new SessionContextPlugin(FuseraftPaths.ExpandSessionPaths(FuseraftPaths.LocalSessionContext, sessionId, slug)); - toolsByCategory["SessionContext"] = PluginRegistry.GetFunctionsFromObject(p).ToList(); - activePlugins.Add(p); - } - - if (enabled.Contains("Scratchpad")) - { - var p = new ScratchpadPlugin("repl", FuseraftPaths.ExpandSessionPaths(FuseraftPaths.LocalSessionScratchpad, sessionId, slug)); - toolsByCategory["Scratchpad"] = PluginRegistry.GetFunctionsFromObject(p).ToList(); - activePlugins.Add(p); - } - } - - using var emitter = new EventEmitter(eventsPath); - emitter.SetSessionId(sessionId); - - var toolArtifactsDir = Path.Combine(cwd, FuseraftPaths.ExpandSessionId(FuseraftPaths.LocalSessionToolArtifacts, sessionId)); - var toolArtifactStore = new ToolResultArtifactStore(toolArtifactsDir, emitter); - foreach (var key in toolsByCategory.Keys.ToList()) - toolsByCategory[key] = toolsByCategory[key] - .Select(f => (AIFunction)new ToolResultLoggingFilter(f, emitter)) - .Select(f => (AIFunction)new ToolResultOffloadFilter(f, toolArtifactStore)) - .ToList(); - - if (explorerTools is not null) - subAgent = new SubAgentPlugin(factory.Create(modelConfig), explorerTools, - eventEmitter: emitter, - parentAgentName: "repl"); - await emitter.EmitAsync(EventTypes.SessionStart, payload: new - { - model = modelId, - cwd, - tools_enabled = !settings.NoTools, - tool_count = initialTools.Count, - resumed = snapshot is not null, - }); - - var memoryStore = MemoryStore.ForRepl(); - var memoryEntries = await memoryStore.LoadAllAsync(cwd, sessionId); - var memoryBlock = memoryEntries.Count > 0 - ? await memoryStore.BuildPromptBlockAsync(cwd, sessionId) - : null; - var systemPrompt = new SystemPromptBuilder() - .AddIdentity(modelId, cwd, initialTools.Count, settings.SystemPrompt) - .AddToolGuidance(initialTools.Count) - .AddSessionInfo(sessionId, startedAt, cwd, initialTools.Count, activePlugins) - .AddProjectInstructions(cwd) - .AddMemory(memoryBlock) - .AddSkills(skillsCatalog) - .Build(); - - if (!jsonMode && !settings.NoBanner) - { - var pluginNames = new List(toolsByCategory.Keys); - if (memoryBlock is not null) pluginNames.Add("Memory"); - - MessageRenderer.RenderReplHeader( - modelId, cwd, pluginNames, sessionId, - memoryCount: memoryEntries.Count, - skillCount: skillsPlugin?.Count ?? 0, - branch: TryGetGitBranch(cwd), - eventsPath: settings.Verbose ? eventsPath : null); - } - - var ctx = new ReplSessionContext( - cwd, sessionId, startedAt, modelId, modelConfig, userCfg, client, - factory, keyStore, emitter, eventsPath, - memoryStore, toolsByCategory, systemPrompt, pendingSave, - verbose: settings.Verbose, subAgent: subAgent) - { - JsonMode = jsonMode, - SkillsPlugin = skillsPlugin, - KeyStored = keyStored, - }; - if (skillsPlugin is not null) - ctx.LineReader.SetSkillSlugs([.. skillsPlugin.Slugs]); - - replSessionPlugin?.SetCompactDelegate(async (focus, ct) => - { - var (success, errorReason, before, after) = - await ReplCommands.CompactHistoryAsync(ctx, focus, ct); - if (!success) - return errorReason == "cancelled" - ? "Compaction cancelled." - : $"ERROR: Compaction failed: {errorReason}"; - return $"Context compacted. Token estimate: {before:N0} → {after:N0} " + - $"(freed ~{before - after:N0} tokens). " + - $"The compact summary is now the active context. Continue the current task from here."; - }); - replSessionPlugin?.SetStatusDelegate( - () => (ctx.EstimateTokens(), ctx.ContextTokenBudget, ctx.TurnIndex)); - - if (snapshot is not null) - { - var restored = snapshot.RestoreHistory(); - if (restored.Count > 0 && restored[0].Role == ChatRole.System) - restored[0] = new ChatMessage(ChatRole.System, systemPrompt); - ctx.History.Clear(); - ctx.History.AddRange(restored); - ctx.TurnIndex = snapshot.TurnIndex; - - if (!jsonMode) - { - AnsiConsole.MarkupLine( - $"[dim] Resuming session [bold]{Markup.Escape(sessionId)}[/] · {snapshot.TurnIndex} turn{(snapshot.TurnIndex == 1 ? "" : "s")} · " + - $"started {Markup.Escape(snapshot.StartedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}[/]"); - } - - if (snapshot.ExecutionQueue is { Length: > 0 }) - { - foreach (var e in snapshot.ExecutionQueue) - ctx.ExecutionQueue.Enqueue((e.Step, e.Total)); - if (!jsonMode) - AnsiConsole.MarkupLine( - $"[dim] Plan in progress: {snapshot.ExecutionQueue.Length} step{(snapshot.ExecutionQueue.Length == 1 ? "" : "s")} queued — resuming automatically[/]"); - } - else if (snapshot.PendingPlan is { Length: > 0 }) - { - ctx.CurrentPlan = snapshot.PendingPlan; - if (!jsonMode) - AnsiConsole.MarkupLine( - $"[dim] Pending plan restored ({snapshot.PendingPlan.Length} step{(snapshot.PendingPlan.Length == 1 ? "" : "s")}). Run /execute to start.[/]"); - } - if (snapshot.HaltedAt is not null) - { - ctx.HaltedAt = (snapshot.HaltedAt.Step, snapshot.HaltedAt.Total); - if (snapshot.HaltedRemaining is { Length: > 0 }) - foreach (var e in snapshot.HaltedRemaining) - ctx.HaltedRemaining.Enqueue((e.Step, e.Total)); - ctx.HaltedToolCalls = [.. snapshot.HaltedToolCalls ?? []]; - ctx.RecoveryHint = snapshot.RecoveryHint; - if (!jsonMode) - AnsiConsole.MarkupLine( - $"[yellow] ⚠ Plan halted at step {snapshot.HaltedAt.Step.Step} of {snapshot.HaltedAt.Total}. Run /recover or /resume.[/]"); - } - - if (!jsonMode) AnsiConsole.WriteLine(); - } - - if (jsonMode) - ReplJsonBridge.Emit(new { type = "ready", sessionId, model = modelId }); - - if (snapshot is null) - _ = ReplTurn.SaveSnapshotAsync(ctx); - - // ── hand off to the next-gen turn loop ────────────────────────────── - await ReplNextTurn.RunAsync(ctx, cancellationToken); - - await emitter.EmitAsync(EventTypes.SessionEnd, payload: new { turns = ctx.TurnIndex }); - await ReplTurn.ExtractMemoriesOnExitAsync(ctx); - - if (userCfg?.SkillCuration?.Enabled == true) - await RunSkillCurationAsync(ctx, userCfg.SkillCuration, loggerFactory, jsonMode); - - if (jsonMode) - ReplJsonBridge.Emit(new { type = "session_end" }); - else - AnsiConsole.MarkupLine("[dim]Session ended.[/]"); - return 0; - } - - // ------------------------------------------------------------------------- - // Private setup helpers (mirrored from ReplCommand) - // ------------------------------------------------------------------------- - - private ShellPolicy? TryLoadDefaultShellPolicy() - { - var candidates = new[] - { - Path.Combine(Directory.GetCurrentDirectory(), ".fuseraft", "config", "orchestration.yaml"), - Path.Combine(Directory.GetCurrentDirectory(), ".fuseraft", "config", "orchestration.json"), - }; - - foreach (var path in candidates) - { - if (!File.Exists(path)) continue; - try - { - var security = OrchestratorBuilder.LoadSecurityConfig(path); - if (security?.ShellPolicy is { } policy) - return policy; - } - catch (Exception ex) - { - loggerFactory.CreateLogger().LogDebug( - ex, "Failed to load shell policy from '{Path}' — REPL will proceed without it.", path); - } - } - - return null; - } - - private static string? ResolveModelId(ReplSettings settings, UserConfig? userCfg) - { - var modelId = settings.Model?.Trim(); - if (!string.IsNullOrEmpty(modelId)) return modelId; - if (userCfg?.IsConfigured == true) return userCfg.ModelId; - foreach (var (env, id) in AutoDetectOrder) - if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(env))) - return id; - return null; - } - - private static string? TryGetGitBranch(string cwd) - { - try - { - using var proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo - { - FileName = "git", - Arguments = "rev-parse --abbrev-ref HEAD", - WorkingDirectory = cwd, - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true, - }); - if (proc is null) return null; - var output = proc.StandardOutput.ReadToEnd().Trim(); - proc.WaitForExit(1000); - return proc.ExitCode == 0 && !string.IsNullOrEmpty(output) && output != "HEAD" ? output : null; - } - catch { return null; } - } - - private static async Task RunSkillCurationAsync( - ReplSessionContext ctx, - SkillCurationConfig curationConfig, - ILoggerFactory loggerFactory, - bool jsonMode) - { - try - { - await ctx.Emitter.EmitAsync(EventTypes.SkillCurationStart, - payload: new { session = ctx.SessionId, source = "repl" }); - - var messages = ctx.History - .Where(m => m.Role == ChatRole.Assistant && !string.IsNullOrWhiteSpace(m.Text)) - .Select((m, i) => new AgentMessage - { - AgentName = AgentNames.Assistant, - Content = m.Text!, - Role = "assistant", - TurnIndex = i, - }) - .ToList(); - - var taskDescription = ctx.History - .FirstOrDefault(m => m.Role == ChatRole.User)?.Text?.Trim() - ?? "REPL session"; - - var checkpoint = new SessionCheckpoint - { - Task = taskDescription, - SessionId = ctx.SessionId, - ConfigPath = string.Empty, - }; - - var curatorModelCfg = curationConfig.Model is { Length: > 0 } m - ? ctx.Factory.Resolve(new ModelConfig { ModelId = m }) - : ctx.ModelConfig; - using var curatorClient = ctx.Factory.Create(curatorModelCfg); - - var curator = new SkillCurator( - curatorClient, - curationConfig, - evidenceStore: null, - loggerFactory.CreateLogger()); - - var result = await curator.RunAsync(checkpoint, messages, CancellationToken.None, source: "repl"); - - await ctx.Emitter.EmitAsync(EventTypes.SkillCurationComplete, - payload: new - { - session = ctx.SessionId, - source = "repl", - outcome = result.Outcome.ToString().ToLowerInvariant(), - slug = result.Slug, - path = result.Path, - turns_digested = result.TurnsDigested, - failure_reason = result.FailureReason, - }); - - if (!jsonMode) - { - if (result.WroteSkill) - AnsiConsole.MarkupLine( - $"[green]✓ Skill {(result.Outcome == SkillCurationOutcome.Updated ? "updated" : "curated")}:[/] " + - $"[bold]{Markup.Escape(result.Slug!)}[/] [dim]{Markup.Escape(result.Path!)}[/]"); - else if (result.Outcome == SkillCurationOutcome.Failed) - AnsiConsole.MarkupLine( - $"[dim yellow]Skill curation failed:[/] {Markup.Escape(result.FailureReason ?? "unknown error")}"); - } - } - catch (Exception ex) - { - try - { - await ctx.Emitter.EmitAsync(EventTypes.SkillCurationComplete, - payload: new { session = ctx.SessionId, source = "repl", outcome = "failed", failure_reason = ex.Message }); - } - catch (Exception emitEx) { loggerFactory.CreateLogger().LogWarning(emitEx, "[SkillCuration] emitter failed: {Message}", emitEx.Message); } - } - } -} diff --git a/src/Cli/Commands/Repl/ReplNextTurn.cs b/src/Cli/Commands/Repl/ReplNextTurn.cs deleted file mode 100644 index 5c6d64ec..00000000 --- a/src/Cli/Commands/Repl/ReplNextTurn.cs +++ /dev/null @@ -1,195 +0,0 @@ -using Microsoft.Extensions.AI; -using Spectre.Console; -using fuseraft.Core.Models; -using fuseraft.Infrastructure; -using fuseraft.Orchestration; - -namespace fuseraft.Cli.Commands.Repl; - -/// -/// Experimental next-generation REPL turn loop. -/// Shares all business logic with ; only the terminal -/// rendering is different. Enable via FUSERAFT_REPL_NEXT=1. -/// -internal static class ReplNextTurn -{ - // ------------------------------------------------------------------------- - // REPL loop - // ------------------------------------------------------------------------- - - internal static async Task RunAsync(ReplSessionContext ctx, CancellationToken cancellationToken) - { - Console.CancelKeyPress += OnCancelKeyPress; - try { await RunLoopAsync(ctx, cancellationToken); } - finally { Console.CancelKeyPress -= OnCancelKeyPress; } - - void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) - { - var c = ctx.ActiveCts; - if (c is not null && !c.IsCancellationRequested) - { - e.Cancel = true; - c.Cancel(); - } - else if (ctx.JsonMode) - { - e.Cancel = true; - ReplJsonBridge.Emit(new { type = "cancelled" }); - } - } - } - - private static async Task RunLoopAsync(ReplSessionContext ctx, CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - if (ctx.ExecutionQueue.Count > 0) - { - var (step, total) = ctx.ExecutionQueue.Dequeue(); - var stepMsg = ReplTurn.BuildStepMessage(step, total); - if (ctx.RecoveryHint is not null) - { - stepMsg = ctx.RecoveryHint + "\n\n" + stepMsg; - ctx.RecoveryHint = null; - } - var historyMarker = ctx.History.Count; - var passed = await ReplTurn.ExecuteAsync( - ctx, - stepMsg, - isStepRequest: true, - capturePlan: false, - activeStep: step, - cancellationToken, - stepTotal: total); - if (passed) - { - while (ctx.History.Count > historyMarker) - ctx.History.RemoveAt(historyMarker); - ctx.History.Add(new ChatMessage(ChatRole.User, - $"[Step {step.Step} of {total} complete] {step.Description}")); - } - await ReplTurn.SaveSnapshotAsync(ctx); - continue; - } - - var turnLabel = (ctx.TurnIndex + 1).ToString(); - if (!ctx.JsonMode) - AnsiConsole.Markup(ctx.SafeMode - ? $"[dim]{turnLabel}[/] [yellow]›[/] " - : $"[dim]{turnLabel}[/] [cyan]›[/] "); - - string? raw; - try { raw = ctx.JsonMode ? ReplJsonBridge.ReadInput() : ctx.LineReader.ReadLine(); } - catch (OperationCanceledException) { break; } - - if (raw is null) break; - - if (ctx.JsonMode && raw == ReplJsonBridge.InterruptToken) - { - var c = ctx.ActiveCts; - if (c is not null && !c.IsCancellationRequested) - c.Cancel(); - continue; - } - - raw = raw.Trim(); - if (string.IsNullOrEmpty(raw)) continue; - - if (raw.StartsWith('/')) - { - var parts = raw.Split(' ', 2, StringSplitOptions.TrimEntries); - var command = parts[0].ToLowerInvariant(); - var arg = parts.Length > 1 ? parts[1] : string.Empty; - - CommandResult result; - if (ctx.JsonMode) - { - using var capture = new StringWriter(); - var savedOut = Console.Out; - var savedAnsiConsole = AnsiConsole.Console; - Console.SetOut(capture); - AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings - { - Out = new AnsiConsoleOutput(capture), - ColorSystem = ColorSystemSupport.NoColors, - Ansi = AnsiSupport.No, - }); - try - { - result = await ReplCommands.HandleAsync(ctx, command, arg, cancellationToken); - } - finally - { - Console.SetOut(savedOut); - AnsiConsole.Console = savedAnsiConsole; - var captured = ReplTurn.StripAnsi(capture.ToString()).Trim(); - if (!string.IsNullOrWhiteSpace(captured)) - ReplJsonBridge.Emit(new { type = "token", text = captured }); - } - } - else - { - result = await ReplCommands.HandleAsync(ctx, command, arg, cancellationToken); - AnsiConsole.WriteLine(); - } - - if (result.Outcome == CommandOutcome.Exit) break; - if (result.Outcome == CommandOutcome.Continue) - { - if (ctx.JsonMode) - ReplJsonBridge.Emit(new { type = "message_end", turnIndex = ctx.TurnIndex, toolCalls = Array.Empty() }); - continue; - } - - await ReplTurn.ExecuteAsync( - ctx, - result.InputOverride!, - isStepRequest: false, - capturePlan: result.CapturePlan, - activeStep: null, - cancellationToken); - _ = ReplTurn.SaveSnapshotAsync(ctx); - continue; - } - - if (raw.StartsWith('$')) - { - var parts = raw.Split(' ', 2, StringSplitOptions.TrimEntries); - var slug = parts[0][1..]; - var args = parts.Length > 1 ? parts[1] : string.Empty; - - if (ctx.SkillsPlugin is null || !ctx.SkillsPlugin.HasSkill(slug)) - { - var available = ctx.SkillsPlugin is not null - ? $"Available: {string.Join(", ", ctx.SkillsPlugin.Slugs.Take(10))}" - : "No skills are loaded in this session."; - var errMsg = string.IsNullOrEmpty(slug) - ? $"Usage: $ [args]. {available}" - : $"Skill '{slug}' not found. {available}"; - if (ctx.JsonMode) - ReplJsonBridge.Emit(new { type = "error", text = errMsg }); - else - AnsiConsole.MarkupLine($"[red]{Markup.Escape(errMsg)}[/]"); - continue; - } - - var skillContent = await ctx.SkillsPlugin.LoadSkillAsync(slug, cancellationToken); - var input = string.IsNullOrEmpty(args) ? skillContent : $"{skillContent}\n\n{args}"; - - await ReplTurn.ExecuteAsync(ctx, input, isStepRequest: false, capturePlan: false, activeStep: null, cancellationToken); - _ = ReplTurn.SaveSnapshotAsync(ctx); - continue; - } - - if (raw.Equals("exit", StringComparison.OrdinalIgnoreCase) || - raw.Equals("quit", StringComparison.OrdinalIgnoreCase)) - break; - - await ReplTurn.ExecuteAsync( - ctx, raw, - isStepRequest: false, capturePlan: false, activeStep: null, - cancellationToken); - _ = ReplTurn.SaveSnapshotAsync(ctx); - } - } -} diff --git a/src/Core/Models/Agents/AgentMessage.cs b/src/Core/Models/Agents/AgentMessage.cs index 6ae094e0..e31c0627 100644 --- a/src/Core/Models/Agents/AgentMessage.cs +++ b/src/Core/Models/Agents/AgentMessage.cs @@ -51,9 +51,7 @@ public record AgentMessage public string Role { get; init; } = MessageRole.Assistant; /// - /// Token usage and estimated cost for this turn. Null for HITL messages. - /// For compaction summary messages, carries the - /// cumulative cost of all compacted turns so budget tracking remains accurate. + /// Token usage for this turn. Null for HITL messages. /// public TokenUsage? Usage { get; init; } diff --git a/src/Program.cs b/src/Program.cs index b6cee74c..bde7d92f 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -136,7 +136,6 @@ services.AddTransient(); services.AddTransient(); services.AddTransient(); -services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -164,12 +163,8 @@ services.AddTransient(); // Use CommandApp so bare `fuseraft` drops straight into the REPL. -// Set FUSERAFT_REPL_NEXT=1 to switch the default entry-point to the new REPL UX. var registrar = new ServiceCollectionRegistrar(services); -bool useNextRepl = Environment.GetEnvironmentVariable("FUSERAFT_REPL_NEXT") is "1" or "true"; -ICommandApp app = useNextRepl - ? new CommandApp(registrar) - : new CommandApp(registrar); +ICommandApp app = new CommandApp(registrar); // MinVer stamps the full semver (including pre-release and git hash) into // AssemblyInformationalVersionAttribute at build time — no manual file needed. @@ -271,10 +266,6 @@ .WithExample(["repl", "--model", "gpt-4o"]) .WithExample(["repl", "--model", "claude-sonnet-4-6", "--system", "You are a helpful coding assistant."]); - cfg.AddCommand("repl-next") - .WithDescription("Next-gen REPL (experimental). Also activated as default via FUSERAFT_REPL_NEXT=1.") - .IsHidden(); - cfg.AddBranch("context", branch => { branch.SetDescription("Manage reference material available to all agents in a session.");