diff --git a/src/lib/ai/runCanvasAgent.ts b/src/lib/ai/runCanvasAgent.ts index fdcf15280c..9cdab3c121 100644 --- a/src/lib/ai/runCanvasAgent.ts +++ b/src/lib/ai/runCanvasAgent.ts @@ -103,6 +103,58 @@ function filterReadonly(tools: ToolSet): ToolSet { return out; } +// --------------------------------------------------------------------------- +// Code-research tool names — stripped when `stripCodeResearchTools: true` is +// requested. +// +// Use case: the plan-mode org-context scout (`scoutOrgContext`) only wants +// canvas/initiative/research/connection context, not deep codebase research. +// Without this filter the scout will reach for `learn_concept` / +// `read_concepts_for_repo` / `repo_agent` and end up reporting on code +// architecture instead of org-level information (initiatives, notes, +// decisions, research docs). +// +// Stripping happens by base name (after the `{slug}__` prefix in multi-WS +// mode). `web_search` is also stripped because the scout should not be +// doing external research. +// +// Tools that are NOT stripped — they're org-context-shaped, not code: +// - list_features / read_feature / list_tasks / read_task / check_status +// (MCP-backed workspace meta — useful for "what's this workspace +// working on" without diving into code) +// - everything in the canvas/initiative/research/connection toolsets. +// --------------------------------------------------------------------------- + +const CODE_RESEARCH_BASE_NAMES: ReadonlySet = new Set([ + "list_concepts", + "learn_concept", + "recent_commits", + "recent_contributions", + "repo_agent", + "search_logs", + "read_concepts_for_repo", +]); + +/** + * Strip the multi-workspace `{slug}__` prefix so we can compare a tool + * name against the base-name list. For single-WS mode (no namespace) + * the input is already the base name. + */ +function baseToolName(name: string): string { + const idx = name.indexOf("__"); + return idx === -1 ? name : name.slice(idx + 2); +} + +function filterCodeResearch(tools: ToolSet): ToolSet { + const out: ToolSet = {}; + for (const [name, def] of Object.entries(tools)) { + if (name === "web_search") continue; // shared, not namespaced + if (CODE_RESEARCH_BASE_NAMES.has(baseToolName(name))) continue; + out[name] = def; + } + return out; +} + // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- @@ -179,6 +231,20 @@ export interface RunCanvasAgentOptions { * (no live UI subscriber) should set it `true`. */ silentPusher?: boolean; + /** + * When `true`, strip codebase-research tools (`learn_concept`, + * `read_concepts_for_repo`, `repo_agent`, `recent_commits`, + * `recent_contributions`, `search_logs`, `list_concepts`) and the + * shared `web_search` tool, leaving only canvas/initiative/ + * research/connection tools plus MCP-backed workspace meta + * (`list_features`, `read_feature`, `list_tasks`, etc.). + * + * Use for callers that want org-level context only — the plan-mode + * org-context scout sets this so the agent doesn't wander into + * code exploration when it should be summarizing canvas notes, + * initiatives, decisions, and research docs. + */ + stripCodeResearchTools?: boolean; /** Caller-owned side effects. */ hooks?: CanvasAgentHooks; } @@ -362,6 +428,7 @@ export async function runCanvasAgent( messages, readonly = false, silentPusher = false, + stripCodeResearchTools = false, hooks, } = opts; @@ -483,6 +550,9 @@ export async function runCanvasAgent( if (readonly) { tools = filterReadonly(tools); } + if (stripCodeResearchTools) { + tools = filterCodeResearch(tools); + } // ------------------------------------------------------------------ // Assemble final message list + sanitize @@ -501,6 +571,7 @@ export async function runCanvasAgent( workspaces: workspaceSlugs, orgId: orgId ?? null, readonly, + stripCodeResearchTools, silentPusher, }); diff --git a/src/services/roadmap/orgContextScout.ts b/src/services/roadmap/orgContextScout.ts index 2526ee4bd8..ca26213769 100644 --- a/src/services/roadmap/orgContextScout.ts +++ b/src/services/roadmap/orgContextScout.ts @@ -252,6 +252,15 @@ async function runScout(args: { // tool set, so this guarantee holds even if a future prompt // change tells the agent to write something. readonly: true, + // Strip codebase-research tools (`learn_concept`, + // `read_concepts_for_repo`, `repo_agent`, etc.) and `web_search`. + // The scout's job is org-level context (initiatives, notes, + // decisions, research docs, connection docs, features in flight + // elsewhere) — not code architecture or external research. + // Without this filter the agent reaches for code-research tools + // when 9+ workspaces are in scope and ends up summarizing code + // instead of canvas content. + stripCodeResearchTools: true, // Programmatic caller, no live UI subscriber — suppress the // HIGHLIGHT_NODES Pusher fan-out that the org chat surface // uses for "the agent is researching node X" animations. @@ -266,18 +275,38 @@ async function runScout(args: { } /** - * Build the scout's user message. Kept intentionally short and high- - * level — we trust the org agent's existing system prompt + canvas - * tools to figure out what's relevant. The plan-request message is - * embedded so the agent can judge relevance against it. + * Build the scout's user message. + * + * The prompt is shaped to steer the agent toward CANVAS content + * (initiatives, milestones, features pinned to workspace canvases, + * research docs, connection docs, authored notes/decisions, edges + * between them) and AWAY from code spelunking. Code-research tools + * are also filtered out at the caller (`stripCodeResearchTools: true` + * on `runCanvasAgent`), so the agent literally cannot reach for + * `learn_concept` / `repo_agent` / web search even if it wanted to — + * but the prompt reinforces intent so the agent doesn't waste turns + * looking for tools that aren't there. + * + * Output format is constrained to terse bullets with no preface so + * the plan agent that consumes this downstream gets clean, scannable + * context instead of conversational framing. */ function buildScoutPrompt(planRequestMessage: string): string { return [ - `A user in this organization is starting plan mode for a new feature. Their request:`, + `A user is starting plan mode for a new feature. Their request:`, "", planRequestMessage.trim(), "", - `Explore the root canvas and the workspace sub-canvases to get a high-level view of the org as a whole. Surface anything relevant to their plan request — be quick. If nothing in the org context seems relevant, reply with exactly: ${NO_CONTEXT_SENTINEL}`, + `Scan the org's CANVASES (root canvas, workspace sub-canvases, initiative sub-canvases) for context that may matter to this plan. Look at: initiatives in flight, milestones, features already pinned to workspaces, research docs, connection docs, authored notes/decisions, edges showing relationships. Do NOT explore code — focus on the planning layer.`, + "", + `Be QUICK. Aim for 3-6 tool calls total.`, + "", + `Output format — STRICT:`, + `- No preamble. No "Based on..." or "Here's what I found...". Just the content.`, + `- Terse bullets, one item per line, each starting with "- ".`, + `- Each bullet: a name in **bold**, then a one-line summary of why it's relevant.`, + `- Max 8 bullets. If you have more, keep only the most relevant.`, + `- If nothing in the org context is meaningfully relevant to this plan, reply with EXACTLY this single token and nothing else: ${NO_CONTEXT_SENTINEL}`, "", `End your reply with [END_OF_ANSWER].`, ].join("\n");