feat(coderd/x/chatd/chatadvisor): add advisor runtime and tool wrapper (#24620)

## Summary

Introduce the `coderd/x/chatd/chatadvisor` package: the self-contained runtime that performs a nested, **tool-less, single-step** model call to return strategic guidance to the parent agent. Also ships the thin `AgentTool` wrapper that the agent exposes as the `advisor` built-in.

## Motivation

The advisor is a "consult before you act" planning step. Keeping its runtime in its own package (rather than inside `chatd.go` or `chattool/`) makes the behavior easy to unit-test, keeps `chattool/` thin, and avoids import cycles with `chatprompt`.

## Changes

- `chatadvisor/types.go`: `AdvisorArgs` and `AdvisorResult` (`advice` / `limit_reached` / `error` variants) plus usage metadata. Stable JSON shape for the UI.
- `chatadvisor/guidance.go`: the nested advisor system prompt and `ParentGuidanceBlock` injected into the outer agent (tagged `<advisor-guidance>`). The nested prompt explicitly tells the advisor it is advising the parent agent, must not address the user, and must not claim actions happened.
- `chatadvisor/handoff.go`: builds the advisor input from the already-prepared outer prompt tail (not a fresh DB reload) with hard truncation budgets and a recent-context bias so the advisor sees the exact context the outer model saw.
- `chatadvisor/runner.go`: wraps `chatloop.Run()` in strict one-step mode (`Tools: nil`, `ProviderTools: nil`, `MaxSteps: 1`) so the nested call is structurally incapable of calling tools.
- `chatadvisor/tool.go`: the `AgentTool` wrapper. Validates the question (non-empty, bounded to 2000 runes), invokes the runtime, and maps the result to a JSON tool response.
- Defensive assertions throughout (question non-empty after trim, positive limits, zero tools on nested call, known result variants, non-negative remaining uses).
- Unit tests cover question validation, tool-less execution, and each result variant.

## Stack context

This is **PR 2 of 6** in the advisor feature stack. It depends on PR 1's `ExclusiveToolNames` hook; no consumer of this package exists yet (that lands in PR 4).

## Scope / non-goals

- No wiring into `chatd`.
- No HTTP/API surface.
- No UI.
- No separate provider; the runtime reuses the outer chat's resolved model/provider/keys.

## Validation

- `go test ./coderd/x/chatd/chatadvisor/...`
- `make lint`

---

<details>
<summary>📋 Implementation Plan (shared across the advisor stack)</summary>

# Plan: Add a Mux-style advisor tool to coder agents/chatd

## Outcome

Add a first-class `advisor` tool to agent chats in `coderd/x/chatd` that feels native to Coder:

- it is a built-in server-side tool, not an MCP/dynamic-tool workaround;
- it performs a nested **tool-less** model call for strategic advice;
- it is exposed only when eligible, and the prompt mentions it only when it is actually available;
- it is treated as a **planning-only** tool so it does not run alongside action tools in the same batch;
- it tracks usage/cost separately enough for operators to reason about it;
- it has a minimally polished UI in the Agents page;
- and it ships with explicit dogfooding evidence, including screenshots and repro videos.

## Design decisions to lock before coding

1. **Primary architecture:** native built-in tool in `chattool/`, backed by a small `chatadvisor` package.
2. **Nested model execution:** reuse chatd's existing model/provider stack for a one-step, tool-less advisor call rather than inventing a new provider pathway.
3. **Execution policy:** treat `advisor` as an exclusive/planning-only tool; mixed batches must return structured policy errors and force the model to retry cleanly.
4. **Availability:** initial rollout is for root agent chats only; disable for child/sub-agent chats until recursion/cost policy is proven.
5. **Prompt sync:** use one eligibility boolean to drive both tool registration and advisor guidance injection.
6. **Persistence/cost split:** MVP should keep advisor usage visible in result metadata and server metrics; only add DB schema if product/billing explicitly needs queryable advisor-specific cost.
7. **UI scope:** generic tool rendering is an acceptable temporary milestone during backend bring-up, but the release candidate should include a dedicated lightweight advisor renderer.

## Delivery model

The work should be executed as coordinated workstreams with one integration owner and parallel contributors for low-conflict areas. The integration owner should own `coderd/x/chatd/chatd.go` because prompt assembly, tool registration, and model resolution all converge there.

## Detailed workstreams

### Repo evidence used for this plan

<details>
<summary>Mux reference and current chatd seams</summary>

**Mux reference implementation**

- `src/node/services/tools/advisor.ts` — native advisor tool implementation.
- `src/common/constants/advisor.ts` — advisor prompt/constants and truncation policy.
- `src/common/utils/tools/tools.ts` — conditional tool registration.
- `src/node/services/streamContextBuilder.ts` — injects advisor guidance only when the tool is available.

**Current chatd seams**

- `coderd/x/chatd/chatd.go`
  - `processChat()` — tool assembly, prompt assembly, and chatloop invocation.
  - `resolveChatModel()` — current model/provider/key resolution seam.
  - `type Config struct` — server-level chatd configuration surface.
- `coderd/x/chatd/chatloop/chatloop.go`
  - `Run()` — main streaming/model loop.
  - `executeTools()` — built-in tool execution/batching seam.
- `coderd/x/chatd/chattool/` — built-in tool implementations.
- `site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx` — tool renderer dispatch.
- `site/src/pages/AgentsPage/components/ChatConversation/messageParsing.ts` and `ConversationTimeline.tsx` — tool/result merge and rendering flow.

</details>

### Workstream map and ownership

| Workstream | Primary owner | Main files | Can run in parallel? | Done when |
|---|---|---|---|---|
| 0. Integration + gating | Integration lead | `coderd/x/chatd/chatd.go` | No; central merge lane | Tool registration, prompt sync, and model selection are wired together |
| 1. Advisor runtime + tool | Backend agent | new `coderd/x/chatd/chatadvisor/`, new `coderd/x/chatd/chattool/advisor.go` | Yes | Tool can perform a tool-less advisor call in memory and return structured results |
| 2. Planning-only execution policy | Chatloop agent | `coderd/x/chatd/chatloop/chatloop.go`, related tests | Yes | Mixed `advisor` + action-tool batches are rejected cleanly and deterministically |
| 3. Metrics/usage/config | Backend/telemetry agent | `chatd.go`, `chatloop/metrics.go`, optional config plumbing | Partially; coordinate with integration lead | Advisor usage is separately visible in metadata/metrics and limits are enforced |
| 4. Frontend rendering | Frontend agent | `site/.../tools/Tool.tsx`, new `AdvisorTool.tsx`, stories | Yes after result schema stabilizes | Advisor renders as a readable card and story tests pass |
| 5. Dogfood + QA evidence | QA agent | dev server, Storybook, dogfood output | After backend + UI are usable | Repro videos, screenshots, and a concise QA report exist |

### Parallelization rules

- **Do not split `coderd/x/chatd/chatd.go` across multiple execution agents without an integration lead.** That file owns prompt building, tool registration, model resolution, and cost persistence.
- Workstreams 1 and 2 can be developed in parallel and then stacked onto the integration branch.
- Workstream 4 should begin once the backend result schema is agreed on, even if the backend is still behind a feature flag.
- Any agent that needs to re-check Mux behavior should clone `coder/mux` into a temporary directory (for example, `$(mktemp -d)/mux`) and inspect it read-only; do not vendor or copy code from Mux directly.

## Phase 0 — Preflight and guardrails

### Goals

- Align the team on the smallest shippable architecture.
- Prevent scope creep into MCP/dynamic-tool/sub-agent variants.
- Decide upfront what is MVP vs. follow-up.

### Tasks

1. **Confirm the MVP boundary.**
   - Ship a built-in advisor tool first.
   - Do **not** make MCP, dynamic tools, or sub-agents the primary implementation.
   - Do **not** add transient streaming phases in the first backend PR unless they fall out almost for free.

2. **Confirm local workflow hygiene before coding.**
   - Ensure the repo is using the project git hooks from `scripts/githooks`.
   - Do not bypass hooks with `--no-verify`.
   - Use `./scripts/develop.sh` for the full dev server rather than manual build/run commands.

3. **Lock the model-selection policy.**
   - **Recommended MVP:** advisor uses the same resolved provider/model/cost config as the current chat, with advisor-specific max-output and usage caps.
   - **Follow-up only if required:** add a separate `AdvisorModelConfigID`-style override that resolves through the existing `configCache`/model-config path. Do not invent a new free-form `provider:model` parser if chatd already stores provider/model separately.

4. **Lock the persistence policy.**
   - **Recommended MVP:** no DB migration. Persist advisor-visible metadata in the tool result and record separate metrics in memory/Prometheus.
   - **Only if product/billing explicitly asks for queryable advisor cost:** add a later DB migration or usage table, following the normal `queries/*.sql` + `make gen` workflow.

5. **Create an execution ADR note in the work item or tracking doc.**
   - Capture: built-in tool, tool-less nested call, root-chat-only rollout, exclusive execution policy, MVP no-DB-migration default.

### Quality gate

- Everyone on the team can state the same answers to these questions:
  - Is advisor a built-in tool? **Yes.**
  - Can advisor run with action tools in the same batch? **No.**
  - Does advisor get tools of its own? **No.**
  - Is a DB migration required for MVP? **No, unless billing insists.**

## Phase 1 — Build the advisor runtime and tool wrapper

### Goals

Create the core advisor implementation in a way that is easy to test and keeps `chattool/` thin.

### Files to add

- `coderd/x/chatd/chatadvisor/types.go`
- `coderd/x/chatd/chatadvisor/guidance.go`
- `coderd/x/chatd/chatadvisor/handoff.go`
- `coderd/x/chatd/chatadvisor/runtime.go`
- `coderd/x/chatd/chatadvisor/runner.go`
- `coderd/x/chatd/chattool/advisor.go`

### Responsibilities by file

1. **`types.go`**
   - Define the input/result schema used by the tool and UI.
   - Keep the result shape close to Mux so the UI and model both have predictable cases.
   - Recommended result variants:
     - `advice`
     - `limit_reached`
     - `error`

   Recommended shape:

   ```go
   type AdvisorArgs struct {
       Question string `json:"question"`
   }

   type AdvisorResult struct {
       Type          string              `json:"type"`
       Advice        string              `json:"advice,omitempty"`
       Error         string              `json:"error,omitempty"`
       AdvisorModel  string              `json:"advisor_model,omitempty"`
       RemainingUses int                 `json:"remaining_uses,omitempty"`
       Usage         *AdvisorUsageResult `json:"usage,omitempty"`
   }
   ```

2. **`guidance.go`**
   - Hold two strings:
     - the nested advisor system prompt;
     - the parent-agent guidance block to inject into the outer system prompt.
   - The nested advisor prompt must say, in plain language:
     - you are advising the parent agent;
     - you do not address the end user directly;
     - you do not claim actions happened;
     - you return concise strategic guidance and tradeoffs.

3. **`runtime.go`**
   - Define the per-run runtime state.
   - Recommended fields:
     - resolved model + model config;
     - provider keys/options reused from the outer chat;
     - `MaxUsesPerRun`;
     - `MaxOutputTokens`;
     - atomic/current call counter;
     - callback(s) to obtain the current prompt snapshot and current-step snapshot;
     - optional metrics/usage hook.
   - Add fail-fast validation for impossible config: nil model, non-positive limits, empty prompt builders, etc.

4. **`handoff.go`**
   - Build the advisor handoff message from:
     - the explicit question;
     - the exact prompt/messages the parent model just used;
     - the current step's text/reasoning snapshot, if available;
     - the most recent relevant tool outputs, if they are already in the prompt snapshot.
   - **Important:** use the already-prepared outer prompt tail, not a fresh DB reload. That keeps the advisor aligned with compaction and the exact context the outer model saw.
   - Apply hard truncation budgets with recent-context bias.

5. **`runner.go`**
   - Execute the nested advisor call.
   - **Recommended implementation:** call `chatloop.Run()` in an in-memory, one-step mode:
     - `Tools: nil`
     - `ProviderTools: nil`
     - `MaxSteps: 1`
     - `PersistStep`: capture the assistant output in memory instead of writing DB rows
   - Reuse the existing provider/model/cost path instead of building a second provider runner.
   - Assert that no tool definitions are passed to the nested call.

6. **`chattool/advisor.go`**
   - Keep this file thin and consistent with other built-ins.
   - Responsibilities:
     - decode `AdvisorArgs`;
     - validate `Question` is non-empty and bounded;
     - call the `chatadvisor` runner;
     - return a structured tool response.

### Defensive programming requirements

- Assert `Question` is non-empty after trimming.
- Assert runtime limits are positive.
- Assert the nested advisor call runs with zero tools/provider tools.
- Assert `AdvisorResult.Type` is one of the known variants before returning.
- Assert remaining uses never goes negative.

### Acceptance criteria

- A unit test can call the advisor tool with a fake model and receive a stable `advice` result.
- The nested advisor call is impossible to run with tools accidentally attached.
- The core logic lives in `chatadvisor/`, not embedded inside `chatd.go`.

## Phase 2 — Wire advisor into chatd and keep prompt/tool availability in sync

### Goals

Register the tool in the right place, expose it only when eligible, and inject system guidance only when the tool is present.

### Files to modify

- `coderd/x/chatd/chatd.go`
- optionally a small helper file if `chatd.go` becomes too crowded

### Tasks

1. **Compute one eligibility boolean in `processChat()`.**
   Recommended inputs:
   - server-level advisor enabled flag;
   - root chat only (`chat.ParentChatID == uuid.Nil` or equivalent existing root/child check);
   - a usable resolved model/provider exists;
   - optional experiment/workspace/org gate if product wants staged rollout.

2. **Create the runtime once per outer chat run.**
   - Use the model/config/keys resolved by `resolveChatModel()`.
   - Reuse provider options from the current chat's `ChatModelCallConfig`.
   - Set `MaxUsesPerRun` and `MaxOutputTokens` from advisor config defaults.

3. **Register the tool in the built-in tool block.**
   - Insert after the skill tools and before MCP tools in `processChat()`.
   - Record `builtinToolNames["advisor"] = true` so metrics stay bounded.

4. **Inject advisor guidance into the outer system prompt using the same boolean.**
   - Use `chatprompt.InsertSystem()` in the same prompt assembly path that already injects user/system instructions.
   - Place the block near the existing instruction insertion, before plan-path/skill context blocks.
   - Wrap the guidance in an explicit tag like `<advisor-guidance>` so it is easy to spot in tests and future refactors.

5. **Keep advisor out of child chats for the first release.**
   - That avoids recursion/cost blowups with `spawn_agent` / `wait_agent` flows.
   - Document this explicitly in the rollout notes and tests.

### Acceptance criteria

- If advisor is disabled, neither the tool nor the prompt guidance appears.
- If advisor is enabled, both the tool and the prompt guidance appear.
- Root chats can use advisor; child chats cannot.
- Built-in tool names include `advisor` so metrics do not collapse it into the generic `mcp` label.

## Phase 3 — Enforce planning-only execution policy in `chatloop`

### Goals

Prevent the model from calling `advisor` and action tools in the same execution batch.

### Files to modify

- `coderd/x/chatd/chatloop/chatloop.go`
- related chatloop tests

### Recommended implementation

Keep the MVP small; do **not** build a general policy engine yet.

1. Add a minimal field to `chatloop.RunOptions`, for example:

   ```go
   ExclusiveToolName *string
   ```

2. In `Run()` / `executeTools()`, detect the case where the exclusive tool appears in the same local-tool batch as any other locally executed tool.

3. When that happens, synthesize structured tool-result errors for the affected calls instead of executing anything in the batch.
   - `advisor` should receive a clear error like: _advisor must be called by itself before action tools_.
   - The sibling action tools should receive a paired policy error like: _this tool was skipped because advisor must run alone_.

4. Let the outer model see those tool errors and retry cleanly.
   - This is simpler and safer than partial execution or hidden deferral.
   - It preserves deterministic transcript history for debugging.

5. Pass the just-finished step snapshot into the tool execution context.
   - The advisor runtime should be able to see the current step's text/reasoning content, because that is often the best hint about what the outer model is trying to decide.

### Why this is the right fit

- It matches the intended semantics: advisor is consulted **before** taking action.
- It avoids subtle race conditions caused by concurrent built-in tool execution.
- It keeps the behavior easy to test with fake models.

### Acceptance criteria

- A model-emitted batch containing only `advisor` succeeds.
- A model-emitted batch containing `advisor` plus any other locally executed tool returns deterministic policy errors and executes nothing.
- Non-advisor tool execution stays unchanged for normal chats.

## Phase 4 — Usage limits, metrics, and configuration

### Goals

Make advisor safe to operate without over-designing billing/storage in the first release.

### Files to modify

- `coderd/x/chatd/chatd.go`
- `coderd/x/chatd/chatloop/metrics.go` as needed
- `coderd/x/chatd/chatd.go` `Config` struct and constructor path
- optional follow-up config/db files only if a separate advisor model or persistent billing is required

### Tasks

1. **Add explicit server config knobs for MVP.**
   Recommended fields on `chatd.Config` or a nested advisor config struct:
   - `AdvisorEnabled bool`
   - `AdvisorMaxUsesPerRun int`
   - `AdvisorMaxOutputTokens int64`

2. **Track usage per outer run.**
   - Reset the counter for each `processChat()` invocation.
   - Return `remaining_uses` in the tool result.
   - Return `limit_reached` when the cap is exhausted.

3. **Expose advisor usage metadata in the tool result.**
   - Include model name and token/cost summary if available.
   - Use the same `callConfig.Cost` calculation path as the outer chat for MVP if advisor reuses the same model.

4. **Record server-side metrics.**
   - Count advisor invocations, failures, and latency.
   - Ensure they show up under the built-in tool label `advisor`.

5. **Optional decision gate: separate advisor model.**
   - If product insists on a stronger/different advisor model, add a follow-up config hook that resolves another existing chat model config through the same `configCache` path.
   - Keep that out of the first landing PR unless it is required for acceptance.

6. **Optional decision gate: queryable advisor cost.**
   - If this becomes required, spin a follow-up DB task:
     - update `coderd/database/queries/*.sql`;
     - add migration files;
     - run `make gen`;
     - update audit mappings if a new auditable type/field is introduced.

### Acceptance criteria

- Advisor calls are capped per outer run.
- Limit exhaustion is user-visible in the tool result.
- Metrics distinguish advisor calls from other built-in tools.
- MVP does not require a schema migration unless explicitly approved.

## Phase 5 — Frontend rendering and Storybook coverage

### Goals

Make advisor feel intentional in the Agents UI without blocking the backend on fancy streaming UI.

### Files to modify

- `site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx`
- new `site/src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.tsx`
- Storybook story file(s) in the same tools directory

### Delivery strategy

1. **Intermediate milestone during backend bring-up:** rely on the existing generic tool renderer if needed.
   - This is acceptable only as a short-lived integration checkpoint.

2. **Release milestone:** add a dedicated lightweight `AdvisorTool` renderer.
   - Reuse existing primitives:
     - `ToolCollapsible`
     - `ToolIcon`
     - `Response` for markdown/prose rendering
     - `ScrollArea` if the advice can be long
   - Keep styling light and consistent with the Agents page.
   - Do not add unnecessary React memoization in `site/src/pages/AgentsPage/`; that area is already React-Compiler aware.

3. **Render the structured result states cleanly.**
   - `advice` — readable prose/markdown with optional metadata footer.
   - `limit_reached` — warning-style message.
   - `error` — error state with visible fallback text.
   - `running` — existing tool loading state/spinner is enough for MVP.

4. **Add Storybook coverage instead of ad-hoc component tests.**
   Recommended stories:
   - successful advice;
   - running/loading;
   - limit reached;
   - error.

5. **Keep the UI contract narrow.**
   - Prefer one text field like `advice` plus small metadata rather than a deeply nested schema.
   - That keeps the UI resilient to prompt iteration.

### Acceptance criteria

- The advisor tool card renders readable content rather than raw quoted JSON in the final release branch.
- Running, limit, and error states are visibly distinct.
- Storybook stories and play assertions cover the new states.
- Existing tool rendering flows remain unchanged.

## Phase 6 — Automated tests and validation gates

### Backend tests to add

1. **Advisor runtime/tool tests**
   - question validation;
   - tool-less nested execution assertion;
   - success result shaping;
   - limit-reached result shaping;
   - error result shaping.

2. **Prompt/gating tests in chatd**
   - advisor disabled ⇒ no tool, no guidance;
   - advisor enabled/root chat ⇒ tool + guidance;
   - child chat ⇒ advisor absent.

3. **Chatloop policy tests**
   - advisor alone runs;
   - advisor + action tool mixed batch returns deterministic policy errors;
   - non-advisor tools still execute normally.

4. **Usage/metrics tests**
   - per-run cap resets correctly;
   - builtin tool labeling includes `advisor`;
   - returned metadata includes model/usage summary when available.

### Frontend tests to add

- Storybook `play()` assertions for the advisor renderer states.
- Verify expand/collapse behavior and visible fallback text.
- Verify the message timeline still renders adjacent tools correctly.

### Recommended command sequence

Run these as the implementation matures, not only at the end:

1. Backend-focused gate after phases 1–4:
   - `make test RUN=TestAdvisor`
   - `make test RUN=TestChatloopAdvisor`
   - `make lint`

2. Frontend-focused gate after phase 5:
   - `pnpm test:storybook src/pages/AgentsPage/components/ChatElements/tools/AdvisorTool.stories.tsx`
   - `pnpm lint`
   - `pnpm format`

3. Final repo gate before handoff:
   - `make pre-commit`
   - run any additional targeted `make test RUN=...` selections covering touched chatd paths

> Use the exact new test names the implementing agents create; the names above are recommended anchors, not existing tests.

## Dogfooding plan

### Principle

Dogfood the change as a real agent feature, not just a unit-tested backend. Per the dogfood and `agent-browser` skills, the reviewer should get **watchable repro videos** plus screenshots that make the behavior obvious without reading logs.

### Required setup

1. Start the full dev environment with:
   - `./scripts/develop.sh`
2. If the frontend renderer changes, also start Storybook from `site/` with:
   - `pnpm storybook --no-open`
3. Use `agent-browser` directly — **never `npx agent-browser`**.
4. Use named browser sessions and an output folder such as:
   - `./dogfood-output/advisor/`
   - with subfolders `screenshots/` and `videos/`

### Evidence protocol

For every interactive scenario below:

1. Start video recording **before** the action.
2. Capture step-by-step screenshots at human pace.
3. Capture one annotated screenshot of the final state.
4. Stop the recording.
5. Note the exact pass/fail observation in the QA report.

For static UI states (for example Storybook error/limit cards), an annotated screenshot is sufficient; video is optional but still encouraged by this project’s review preference.

### Dogfood scenarios

#### Scenario A — Happy path in the real Agents UI

**Goal:** prove that a root agent chat can invoke advisor and produce a readable recommendation before taking further action.

Steps:

1. Open the Agents page with an advisor-enabled root chat.
2. Start a repro video.
3. Send a prompt that should reasonably trigger strategic planning, such as an architecture or multi-tradeoff question.
4. Capture screenshots of:
   - the prompt before send;
   - the running advisor state;
   - the completed advisor card and the assistant’s follow-up response.
5. Stop recording.

Pass criteria:

- advisor appears in the timeline;
- the rendered result is readable;
- the assistant can continue after consuming the advisor output.

#### Scenario B — Advisor unavailable path

**Goal:** prove the feature is truly gated.

Suggested variants (at least one is required, both are better):

- feature flag/config off;
- child/sub-agent chat.

Evidence:

- annotated screenshot of the chat/tool state showing advisor is absent;
- short video if toggling the gate live is part of the repro.

Pass criteria:

- no advisor tool is available;
- no advisor-specific prompt behavior leaks through.

#### Scenario C — UI states in Storybook

**Goal:** prove the renderer handles non-happy states cleanly.

Required story states:

- success/advice;
- running;
- limit reached;
- error.

Evidence:

- one screenshot per state;
- at least one short video showing collapse/expand behavior.

Pass criteria:

- success renders readable advice;
- limit/error have visible fallback text;
- the component behaves like the other tool cards.

#### Scenario D — Regression sweep of nearby tools

**Goal:** ensure advisor does not break the surrounding chat timeline.

Check at minimum:

- another existing built-in tool still renders correctly near advisor;
- sub-agent/tool cards still expand/collapse normally;
- no obvious console errors appear in the Agents page during the advisor flow.

Evidence:

- screenshots of adjacent tool cards;
- console/error capture if anything suspicious appears.

### `agent-browser` usage notes for the QA agent

- Prefer `agent-browser batch` for 2+ sequential commands when no intermediate parsing is needed.
- Use `snapshot -i` to discover interactive refs.
- Re-snapshot after navigation or major DOM changes.
- Avoid `wait --load networkidle` unless the page is known to go idle; prefer explicit element/text waits or short fixed waits.
- Record videos at human pace and include pauses that a reviewer can follow.

## Rollout plan

### Initial rollout

- Gate behind a server-side advisor-enabled flag.
- Enable only for selected internal/root agent chats first.
- Watch metrics for:
  - invocation count;
  - failure rate;
  - latency;
  - obvious retry loops.

### Expansion conditions

Expand beyond the initial rollout only after the following are true:

- mixed-batch policy behavior is stable;
- cost impact is understood;
- frontend UX is readable in production-like dogfood;
- no recursion surprises have appeared with sub-agent flows.

### Explicit non-goals for the first release

- advisor inside child/sub-agent chats;
- provider-agnostic streaming phase UI;
- MCP-based external advisor implementation;
- mandatory DB-backed advisor cost reporting.

## Final acceptance checklist

- [ ] `advisor` is a built-in chatd tool, not an MCP/dynamic-tool substitute.
- [ ] The nested advisor call is tool-less and bounded to one in-memory step.
- [ ] One eligibility boolean controls both tool registration and prompt guidance injection.
- [ ] Root chats can use advisor; child chats cannot in the initial rollout.
- [ ] Mixed advisor/action batches produce deterministic policy errors instead of partial execution.
- [ ] Per-run usage caps and limit-reached behavior work.
- [ ] Advisor usage is visible in metadata/metrics without forcing a DB migration for MVP.
- [ ] The Agents UI has a readable advisor card and Storybook coverage.
- [ ] Dogfooding produced screenshots and repro videos for the required scenarios.
- [ ] Validation commands (`make lint`, targeted `make test`, Storybook tests, `make pre-commit`) passed before handoff.

## Suggested PR split

1. **PR 1 — Backend foundation**
   - `chatadvisor/` package
   - `chattool/advisor.go`
   - `chatloop` exclusive policy
   - chatd gating/prompt sync
   - backend tests

2. **PR 2 — Frontend + QA**
   - advisor renderer
   - stories/play assertions
   - dogfood artifacts and QA notes

3. **PR 3 — Optional follow-ups only if demanded by stakeholders**
   - separate advisor model override
   - persistent advisor billing/queryability
   - transient phase-stream UX


</details>

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `anthropic:claude-opus-4-7` • Thinking: `max`_
This commit is contained in:
Thomas Kosiewski
2026-04-30 14:22:33 +02:00
committed by GitHub
parent a84534315c
commit eaf2609bb8
8 changed files with 1411 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
package chatadvisor
const (
// AdvisorSystemPrompt steers the nested advisor model to help the parent
// agent rather than speaking directly to the end user.
AdvisorSystemPrompt = `You are an internal advisor for another AI coding agent.
You are advising the parent agent, not the end user.
Give concise strategic guidance that helps the parent decide what to do next.
Focus on planning ambiguity, architecture tradeoffs, debugging strategy,
and risk reduction.
Do not address the user directly.
Do not suggest using tools yourself because this nested run has no tools.
Respond with practical guidance only.`
// ParentGuidanceBlock is a reusable prompt block for teaching parent agents
// when to invoke the built-in advisor tool.
ParentGuidanceBlock = `<advisor-guidance>
Use the built-in advisor tool when you need strategic guidance on planning
ambiguity, architectural tradeoffs, debugging strategy, or repeated failures.
The advisor sees recent conversation context, runs as a single-step nested model
call with no tools, and returns concise guidance for the parent agent rather
than the end user.
</advisor-guidance>`
)
+208
View File
@@ -0,0 +1,208 @@
package chatadvisor
import (
"encoding/json"
"maps"
"slices"
"strings"
"charm.land/fantasy"
)
const (
// advisorRecentMessageLimit caps how many recent non-system messages
// from the parent conversation are forwarded to the advisor. The
// advisor only needs enough tail to ground its guidance, not the full
// history.
advisorRecentMessageLimit = 20
// advisorConversationJSONByteBudget caps the combined size of the
// forwarded recent messages, measured as JSON-serialized bytes (not
// raw text runes). The JSON wrapping inflates the count relative to
// user-visible text, so the effective text budget is smaller than the
// number suggests. The walk stops at the first message that would
// overflow, trading breadth for contiguity.
advisorConversationJSONByteBudget = 12000
// advisorSystemJSONByteBudget caps the combined size of inherited
// system messages forwarded to the advisor. Without a cap, a large
// parent system prompt (long injected instructions, accumulated
// context) could push the advisor call past the model's context
// window on top of the advisor contract, the recent tail, and the
// question, surfacing as a provider error instead of advice.
advisorSystemJSONByteBudget = 12000
defaultAdvisorQuestion = "Provide concise strategic guidance for the parent agent."
)
// BuildAdvisorMessages prepares a nested advisor prompt using the recent chat
// context plus the explicit advisor question.
func BuildAdvisorMessages(
question string,
conversationSnapshot []fantasy.Message,
) []fantasy.Message {
trimmedQuestion := strings.TrimSpace(question)
if trimmedQuestion == "" {
trimmedQuestion = defaultAdvisorQuestion
}
messages := make([]fantasy.Message, 0, len(conversationSnapshot)+2)
// Place inherited system messages before AdvisorSystemPrompt so the
// advisor contract is the final system instruction the model sees.
// Later system directives win when they conflict, and the parent's
// prompt may tell the model to address the end user directly or use
// tools. The advisor must override those behaviors, not be overridden
// by them.
//
// Walk system messages newest-to-oldest when consuming the byte
// budget so that truncation preserves the most recent directives.
// The parent may have injected recent safety or user-instruction
// blocks that should win over older foundational prompts, and later
// directives override earlier ones anyway. After selection, restore
// the original order before appending so the advisor still sees the
// parent's intended directive sequence.
inheritedSystem := make([]fantasy.Message, 0)
remainingSystemBudget := advisorSystemJSONByteBudget
for i := len(conversationSnapshot) - 1; i >= 0; i-- {
msg := conversationSnapshot[i]
if msg.Role != fantasy.MessageRoleSystem {
continue
}
messageBytes := messageJSONByteCount(msg)
if messageBytes > remainingSystemBudget {
// Skip oversized inherited system messages rather
// than forwarding them wholesale. A single massive
// parent system prompt could otherwise push the
// advisor prompt past the model's context window,
// returning a provider error instead of advice.
// Continue walking so smaller older directives can
// still contribute; stopping here would drop them
// solely because a newer sibling was oversized.
continue
}
inheritedSystem = append(inheritedSystem, cloneMessage(msg))
remainingSystemBudget -= messageBytes
}
slices.Reverse(inheritedSystem)
messages = append(messages, inheritedSystem...)
messages = append(messages, textMessage(fantasy.MessageRoleSystem, AdvisorSystemPrompt))
recent := make([]fantasy.Message, 0, min(len(conversationSnapshot), advisorRecentMessageLimit))
remainingBudget := advisorConversationJSONByteBudget
for i := len(conversationSnapshot) - 1; i >= 0; i-- {
msg := conversationSnapshot[i]
if msg.Role == fantasy.MessageRoleSystem {
continue
}
if len(recent) >= advisorRecentMessageLimit {
break
}
messageBytes := messageJSONByteCount(msg)
if messageBytes > remainingBudget {
// Stop at the first message that doesn't fit so the
// advisor window stays contiguous from most recent
// backward. Skipping an oversized message would leave
// the advisor with an invisible hole in the history,
// where later messages reference context that is no
// longer present.
break
}
recent = append(recent, cloneMessage(msg))
remainingBudget -= messageBytes
}
slices.Reverse(recent)
recent = dropOrphanToolMessages(recent)
messages = append(messages, recent...)
messages = append(messages, textMessage(fantasy.MessageRoleUser, trimmedQuestion))
return messages
}
// dropOrphanToolMessages removes tool-role messages whose tool-call references
// have been truncated out of the recent window. Providers reject prompts with
// tool_result blocks that do not have a matching tool_use, so a truncation cut
// that lands between an assistant tool-call message and its tool-result message
// would otherwise produce a provider error rather than advice. The backward
// walk always picks up tool results before their originating assistant
// message, so orphan results can only appear at the leading edge of the
// recent window. A single forward pass tracking known tool-call IDs is
// sufficient to drop them.
func dropOrphanToolMessages(recent []fantasy.Message) []fantasy.Message {
if len(recent) == 0 {
return recent
}
known := make(map[string]struct{})
result := make([]fantasy.Message, 0, len(recent))
for _, msg := range recent {
if msg.Role == fantasy.MessageRoleAssistant {
for _, part := range msg.Content {
call, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](part)
if !ok {
continue
}
known[call.ToolCallID] = struct{}{}
}
result = append(result, msg)
continue
}
if msg.Role != fantasy.MessageRoleTool {
result = append(result, msg)
continue
}
kept := make([]fantasy.MessagePart, 0, len(msg.Content))
for _, part := range msg.Content {
tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
if !ok {
kept = append(kept, part)
continue
}
if _, matched := known[tr.ToolCallID]; matched {
kept = append(kept, part)
}
}
if len(kept) == 0 {
continue
}
trimmed := msg
trimmed.Content = kept
result = append(result, trimmed)
}
return result
}
func textMessage(role fantasy.MessageRole, text string) fantasy.Message {
return fantasy.Message{
Role: role,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: text},
},
}
}
func cloneMessage(msg fantasy.Message) fantasy.Message {
cloned := msg
cloned.Content = append([]fantasy.MessagePart(nil), msg.Content...)
cloned.ProviderOptions = maps.Clone(msg.ProviderOptions)
return cloned
}
// messageJSONByteCount approximates the message's contribution to the
// advisor prompt using the length of its JSON serialization. The JSON
// wrapping ({"role":"...","content":[{"type":"text","text":"..."}]}) is
// counted alongside the user-visible text; the measurement is intended
// for budget accounting, not for reporting visible character counts.
func messageJSONByteCount(msg fantasy.Message) int {
data, err := json.Marshal(msg)
if err == nil {
return len(data)
}
total := 0
for _, part := range msg.Content {
partData, partErr := json.Marshal(part)
if partErr == nil {
total += len(partData)
}
}
return total
}
+98
View File
@@ -0,0 +1,98 @@
package chatadvisor
import (
"context"
"strings"
"charm.land/fantasy"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
)
// RunAdvisor executes a single, tool-less nested advisor call.
func (rt *Runtime) RunAdvisor(
ctx context.Context,
question string,
conversationSnapshot []fantasy.Message,
) (AdvisorResult, error) {
// Model, MaxUsesPerRun, and MaxOutputTokens are validated by NewRuntime.
// Runtime fields are unexported so callers cannot bypass that.
if strings.TrimSpace(question) == "" {
return AdvisorResult{}, xerrors.New("advisor question is required")
}
if !rt.tryAcquire() {
return AdvisorResult{
Type: ResultTypeLimitReached,
RemainingUses: 0,
}, nil
}
// Clone per invocation and reset inherited state so chatloop cannot
// mutate the Runtime's stored options across calls, and so the nested
// call never runs as a chain-mode continuation against stale parent
// state or persists an orphan stored response on the provider side.
nestedProviderOptions := cloneProviderOptions(rt.cfg.ProviderOptions)
resetProviderOptionsForNestedCall(nestedProviderOptions)
var persistedStep chatloop.PersistedStep
runOpts := chatloop.RunOptions{
Model: rt.cfg.Model,
Messages: BuildAdvisorMessages(question, conversationSnapshot),
MaxSteps: 1,
ModelConfig: rt.cfg.ModelConfig,
ProviderOptions: nestedProviderOptions,
PersistStep: func(_ context.Context, step chatloop.PersistedStep) error {
persistedStep = step
return nil
},
}
if err := chatloop.Run(ctx, runOpts); err != nil {
// Refund the use so a transient provider failure does not
// permanently exhaust the per-run advisor budget.
rt.release()
return AdvisorResult{
Type: ResultTypeError,
Error: err.Error(),
RemainingUses: rt.RemainingUses(),
}, nil
}
advice := extractAdvisorText(persistedStep)
if advice == "" {
// Refund: the run did not produce advice, so the contract
// "increments on every successful advisor call" treats this
// as not consuming a use.
rt.release()
return AdvisorResult{
Type: ResultTypeError,
Error: "advisor produced no text output",
RemainingUses: rt.RemainingUses(),
}, nil
}
return AdvisorResult{
Type: ResultTypeAdvice,
Advice: advice,
AdvisorModel: rt.cfg.Model.Provider() + "/" + rt.cfg.Model.Model(),
RemainingUses: rt.RemainingUses(),
}, nil
}
func extractAdvisorText(step chatloop.PersistedStep) string {
parts := make([]string, 0, len(step.Content))
for _, content := range step.Content {
text, ok := fantasy.AsContentType[fantasy.TextContent](content)
if !ok {
continue
}
trimmed := strings.TrimSpace(text.Text)
if trimmed == "" {
continue
}
parts = append(parts, trimmed)
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}
+585
View File
@@ -0,0 +1,585 @@
package chatadvisor_test
import (
"context"
"fmt"
"iter"
"strings"
"testing"
"charm.land/fantasy"
fantasyopenai "charm.land/fantasy/providers/openai"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/x/chatd/chatadvisor"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
"github.com/coder/coder/v2/codersdk"
)
func TestAdvisorRunAdvice(t *testing.T) {
t.Parallel()
const (
question = "What is the smallest safe change?"
maxOutputTokens = int64(321)
)
var capturedCall fantasy.Call
runtime, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Model: &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
StreamFn: func(_ context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
capturedCall = call
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "Take the smallest safe change."},
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
}), nil
},
},
MaxUsesPerRun: 2,
MaxOutputTokens: maxOutputTokens,
})
require.NoError(t, err)
result, err := runtime.RunAdvisor(t.Context(), question, []fantasy.Message{
textMessage(fantasy.MessageRoleSystem, "existing system"),
textMessage(fantasy.MessageRoleUser, "hello"),
})
require.NoError(t, err)
require.Equal(t, chatadvisor.ResultTypeAdvice, result.Type)
require.Equal(t, "Take the smallest safe change.", result.Advice)
require.Equal(t, "test-provider/test-model", result.AdvisorModel)
require.Equal(t, 1, result.RemainingUses)
require.Empty(t, capturedCall.Tools)
require.NotNil(t, capturedCall.MaxOutputTokens)
require.Equal(t, maxOutputTokens, *capturedCall.MaxOutputTokens)
require.NotEmpty(t, capturedCall.Prompt)
require.Equal(t, fantasy.MessageRoleUser, capturedCall.Prompt[len(capturedCall.Prompt)-1].Role)
require.Equal(t, question, singleText(t, capturedCall.Prompt[len(capturedCall.Prompt)-1]))
}
func TestAdvisorRunLimitReached(t *testing.T) {
t.Parallel()
var calls int
runtime, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Model: &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
calls++
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "first answer"},
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
}), nil
},
},
MaxUsesPerRun: 1,
MaxOutputTokens: 64,
})
require.NoError(t, err)
first, err := runtime.RunAdvisor(t.Context(), "first?", nil)
require.NoError(t, err)
require.Equal(t, chatadvisor.ResultTypeAdvice, first.Type)
require.Equal(t, 0, first.RemainingUses)
second, err := runtime.RunAdvisor(t.Context(), "second?", nil)
require.NoError(t, err)
require.Equal(t, chatadvisor.ResultTypeLimitReached, second.Type)
require.Equal(t, 0, second.RemainingUses)
require.Equal(t, 1, calls)
}
func TestAdvisorRunError(t *testing.T) {
t.Parallel()
runtime, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Model: &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return nil, xerrors.New("boom")
},
},
MaxUsesPerRun: 1,
MaxOutputTokens: 64,
})
require.NoError(t, err)
result, err := runtime.RunAdvisor(t.Context(), "what failed?", nil)
require.NoError(t, err)
require.Equal(t, chatadvisor.ResultTypeError, result.Type)
require.Contains(t, result.Error, "boom")
// A transient nested run failure must not consume quota: callers
// can retry up to MaxUsesPerRun times despite the failure.
require.Equal(t, 1, result.RemainingUses)
// Confirm the refund left the runtime in a usable state by issuing
// a successful call after the failure, even though MaxUsesPerRun=1.
runtime2, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Model: &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
StreamFn: func() func(context.Context, fantasy.Call) (fantasy.StreamResponse, error) {
var calls int
return func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
calls++
if calls == 1 {
return nil, xerrors.New("boom")
}
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "recovered"},
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
}), nil
}
}(),
},
MaxUsesPerRun: 1,
MaxOutputTokens: 64,
})
require.NoError(t, err)
failed, err := runtime2.RunAdvisor(t.Context(), "first?", nil)
require.NoError(t, err)
require.Equal(t, chatadvisor.ResultTypeError, failed.Type)
require.Equal(t, 1, failed.RemainingUses)
retried, err := runtime2.RunAdvisor(t.Context(), "retry?", nil)
require.NoError(t, err)
require.Equal(t, chatadvisor.ResultTypeAdvice, retried.Type)
require.Equal(t, "recovered", retried.Advice)
require.Equal(t, 0, retried.RemainingUses)
}
func TestNewRuntimeValidation(t *testing.T) {
t.Parallel()
matchingTokens := int64(64)
mismatchedTokens := int64(32)
model := &chattest.FakeModel{ProviderName: "test-provider", ModelName: "test-model"}
tests := []struct {
name string
cfg chatadvisor.RuntimeConfig
errText string
}{
{
name: "NilModel",
cfg: chatadvisor.RuntimeConfig{MaxUsesPerRun: 1, MaxOutputTokens: 64},
errText: "advisor model is required",
},
{
name: "NonPositiveMaxUses",
cfg: chatadvisor.RuntimeConfig{
Model: model,
MaxUsesPerRun: 0,
MaxOutputTokens: 64,
},
errText: "advisor max uses per run must be positive",
},
{
name: "NonPositiveMaxOutputTokens",
cfg: chatadvisor.RuntimeConfig{
Model: model,
MaxUsesPerRun: 1,
MaxOutputTokens: 0,
},
errText: "advisor max output tokens must be positive",
},
{
name: "MismatchedModelConfigMaxOutputTokens",
cfg: chatadvisor.RuntimeConfig{
Model: model,
MaxUsesPerRun: 1,
MaxOutputTokens: matchingTokens,
ModelConfig: codersdk.ChatModelCallConfig{
MaxOutputTokens: &mismatchedTokens,
},
},
errText: "must match runtime max output tokens",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
_, err := chatadvisor.NewRuntime(testCase.cfg)
require.Error(t, err)
require.ErrorContains(t, err, testCase.errText)
})
}
}
func TestNewRuntimeDeepClonesOpenAIResponsesProviderOptions(t *testing.T) {
t.Parallel()
parentPrevID := "resp_parent_abc123"
parentOpts := &fantasyopenai.ResponsesProviderOptions{
PreviousResponseID: &parentPrevID,
}
parentProviderOpts := fantasy.ProviderOptions{
fantasyopenai.Name: parentOpts,
}
runtime, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Model: &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "advice"},
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
}), nil
},
},
ProviderOptions: parentProviderOpts,
MaxUsesPerRun: 1,
MaxOutputTokens: 64,
})
require.NoError(t, err)
result, err := runtime.RunAdvisor(t.Context(), "anything?", nil)
require.NoError(t, err)
require.Equal(t, chatadvisor.ResultTypeAdvice, result.Type)
// Parent's OpenAI Responses entry must still carry its PreviousResponseID;
// the advisor's nested chatloop run must not have mutated the shared pointer.
require.NotNil(t, parentOpts.PreviousResponseID)
require.Equal(t, parentPrevID, *parentOpts.PreviousResponseID)
}
func TestAdvisorRunStripsChainStateAndIsConsistentAcrossCalls(t *testing.T) {
t.Parallel()
parentPrevID := "resp_parent_xyz"
parentOpts := &fantasyopenai.ResponsesProviderOptions{
PreviousResponseID: &parentPrevID,
}
parentProviderOpts := fantasy.ProviderOptions{
fantasyopenai.Name: parentOpts,
}
// Snapshot PreviousResponseID and Store at stream time, before chatloop
// has any chance to clear them on the shared map. Comparing across calls
// proves the advisor observes consistent (non-chained, non-persisted)
// options each invocation.
type observedOpts struct {
prevID *string
store *bool
}
var observed []observedOpts
runtime, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Model: &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
StreamFn: func(_ context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
openaiOpts, ok := call.ProviderOptions[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions)
if !ok {
observed = append(observed, observedOpts{})
} else {
snap := observedOpts{}
if openaiOpts.PreviousResponseID != nil {
copied := *openaiOpts.PreviousResponseID
snap.prevID = &copied
}
if openaiOpts.Store != nil {
copied := *openaiOpts.Store
snap.store = &copied
}
observed = append(observed, snap)
}
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "advice"},
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
}), nil
},
},
ProviderOptions: parentProviderOpts,
MaxUsesPerRun: 2,
MaxOutputTokens: 64,
})
require.NoError(t, err)
for i := range 2 {
result, err := runtime.RunAdvisor(t.Context(), fmt.Sprintf("q%d", i), nil)
require.NoError(t, err)
require.Equal(t, chatadvisor.ResultTypeAdvice, result.Type)
}
require.Len(t, observed, 2)
for i, snap := range observed {
// Each nested call must run without chain mode so prompts built
// from full history by BuildAdvisorMessages are accepted.
require.Nil(t, snap.prevID, "call %d unexpectedly ran in chain mode", i)
// Store must be explicitly disabled so the provider does not
// persist an orphan response that later chain-mode calls would
// fail to resume.
require.NotNil(t, snap.store, "call %d did not disable Store", i)
require.False(t, *snap.store, "call %d ran with Store enabled", i)
}
// The parent's pointer must be untouched across repeated advisor runs.
require.NotNil(t, parentOpts.PreviousResponseID)
require.Equal(t, parentPrevID, *parentOpts.PreviousResponseID)
}
func TestBuildAdvisorMessagesTruncatesToRecentMessageLimit(t *testing.T) {
t.Parallel()
snapshot := []fantasy.Message{textMessage(fantasy.MessageRoleSystem, "existing system")}
for i := range 25 {
snapshot = append(snapshot, textMessage(fantasy.MessageRoleUser, fmt.Sprintf("msg-%02d", i)))
}
messages := chatadvisor.BuildAdvisorMessages("Need advice", snapshot)
// cloned existing system + advisor system + 20 most recent user messages + question.
require.Len(t, messages, 23)
require.Equal(t, fantasy.MessageRoleSystem, messages[0].Role)
require.Equal(t, "existing system", singleText(t, messages[0]))
require.Equal(t, fantasy.MessageRoleSystem, messages[1].Role)
require.Contains(t, singleText(t, messages[1]), "parent agent")
require.Equal(t, "msg-05", singleText(t, messages[2]))
require.Equal(t, "msg-24", singleText(t, messages[len(messages)-2]))
require.Equal(t, "Need advice", singleText(t, messages[len(messages)-1]))
}
func TestBuildAdvisorMessagesStopsAtOversizedMessage(t *testing.T) {
t.Parallel()
// The walk is backward from the end of the snapshot. user-late fits,
// the oversized assistant message breaks the walk, and user-early is
// never reached. This preserves contiguity: the advisor never sees a
// message that references missing context.
snapshot := []fantasy.Message{
textMessage(fantasy.MessageRoleSystem, "existing system"),
textMessage(fantasy.MessageRoleUser, "user-early"),
textMessage(fantasy.MessageRoleAssistant, strings.Repeat("x", 20000)),
textMessage(fantasy.MessageRoleUser, "user-late"),
}
messages := chatadvisor.BuildAdvisorMessages("Need advice", snapshot)
require.Len(t, messages, 4)
require.Equal(t, fantasy.MessageRoleSystem, messages[0].Role)
require.Equal(t, "existing system", singleText(t, messages[0]))
require.Equal(t, fantasy.MessageRoleSystem, messages[1].Role)
require.Contains(t, singleText(t, messages[1]), "parent agent")
require.Equal(t, "user-late", singleText(t, messages[2]))
require.Equal(t, "Need advice", singleText(t, messages[3]))
for _, msg := range messages {
require.NotContains(t, singleText(t, msg), strings.Repeat("x", 100))
}
}
func TestBuildAdvisorMessagesPlacesAdvisorPromptAfterInheritedSystem(t *testing.T) {
t.Parallel()
snapshot := []fantasy.Message{
textMessage(fantasy.MessageRoleSystem, "parent-first"),
textMessage(fantasy.MessageRoleSystem, "parent-second"),
textMessage(fantasy.MessageRoleUser, "hello"),
}
messages := chatadvisor.BuildAdvisorMessages("Need advice", snapshot)
// Inherited system messages come first in their original order, then
// the advisor contract, then the recent tail, then the question.
// This ordering makes the advisor prompt the last system directive
// so it wins over conflicting parent instructions.
require.Len(t, messages, 5)
require.Equal(t, fantasy.MessageRoleSystem, messages[0].Role)
require.Equal(t, "parent-first", singleText(t, messages[0]))
require.Equal(t, fantasy.MessageRoleSystem, messages[1].Role)
require.Equal(t, "parent-second", singleText(t, messages[1]))
require.Equal(t, fantasy.MessageRoleSystem, messages[2].Role)
require.Contains(t, singleText(t, messages[2]), "parent agent")
require.Equal(t, fantasy.MessageRoleUser, messages[3].Role)
require.Equal(t, "hello", singleText(t, messages[3]))
require.Equal(t, fantasy.MessageRoleUser, messages[4].Role)
require.Equal(t, "Need advice", singleText(t, messages[4]))
}
func TestBuildAdvisorMessagesDropsOversizedInheritedSystem(t *testing.T) {
t.Parallel()
// A single oversized parent system message is skipped so it cannot
// push the advisor prompt past the model's context window. Smaller
// system messages that fit the budget survive, as do later non-system
// messages.
snapshot := []fantasy.Message{
textMessage(fantasy.MessageRoleSystem, "small-system"),
textMessage(fantasy.MessageRoleSystem, strings.Repeat("x", 20000)),
textMessage(fantasy.MessageRoleUser, "hello"),
}
messages := chatadvisor.BuildAdvisorMessages("Need advice", snapshot)
// small-system + advisor system + recent user + question. The
// oversized inherited system message must not appear.
require.Len(t, messages, 4)
require.Equal(t, fantasy.MessageRoleSystem, messages[0].Role)
require.Equal(t, "small-system", singleText(t, messages[0]))
require.Equal(t, fantasy.MessageRoleSystem, messages[1].Role)
require.Contains(t, singleText(t, messages[1]), "parent agent")
require.Equal(t, fantasy.MessageRoleUser, messages[2].Role)
require.Equal(t, "hello", singleText(t, messages[2]))
require.Equal(t, fantasy.MessageRoleUser, messages[3].Role)
require.Equal(t, "Need advice", singleText(t, messages[3]))
for _, msg := range messages {
require.NotContains(t, singleText(t, msg), strings.Repeat("x", 100))
}
}
func TestBuildAdvisorMessagesPrefersNewestSystemDirectivesUnderBudget(t *testing.T) {
t.Parallel()
// Two parent system messages together exceed the advisor system byte
// budget, so one must be dropped. Later directives override earlier
// ones when they conflict, so the advisor must receive the newest
// directive and drop the older one. Preserve original order among
// messages that survive so the parent's intended directive sequence
// is unchanged.
const payload = 9000
snapshot := []fantasy.Message{
textMessage(fantasy.MessageRoleSystem, "older-"+strings.Repeat("a", payload)),
textMessage(fantasy.MessageRoleSystem, "newer-"+strings.Repeat("b", payload)),
textMessage(fantasy.MessageRoleUser, "hello"),
}
messages := chatadvisor.BuildAdvisorMessages("Need advice", snapshot)
// newer parent system + advisor system + recent user + question. The
// older system message must be dropped because the newer directive
// consumed the remaining budget.
require.Len(t, messages, 4)
require.Equal(t, fantasy.MessageRoleSystem, messages[0].Role)
require.Contains(t, singleText(t, messages[0]), "newer-")
require.NotContains(t, singleText(t, messages[0]), "older-")
require.Equal(t, fantasy.MessageRoleSystem, messages[1].Role)
require.Contains(t, singleText(t, messages[1]), "parent agent")
require.Equal(t, fantasy.MessageRoleUser, messages[2].Role)
require.Equal(t, "hello", singleText(t, messages[2]))
require.Equal(t, fantasy.MessageRoleUser, messages[3].Role)
require.Equal(t, "Need advice", singleText(t, messages[3]))
}
func TestBuildAdvisorMessagesDropsOrphanToolResults(t *testing.T) {
t.Parallel()
// Simulate a truncation cut that lands between the assistant tool-call
// message and its tool-result. The resulting recent window should not
// contain an orphan tool_result referencing a missing tool_use block.
// Building the window with only [tool_result, assistant_reply] mimics
// the state produced by the backward walk hitting its byte budget right
// before the tool-call assistant message.
snapshot := []fantasy.Message{
toolResultMessage("call-1", "ok"),
textMessage(fantasy.MessageRoleAssistant, "final reply"),
}
messages := chatadvisor.BuildAdvisorMessages("Need advice", snapshot)
// Advisor system + assistant reply + question. The orphan tool result
// must not appear in the advisor prompt.
require.Len(t, messages, 3)
require.Equal(t, fantasy.MessageRoleSystem, messages[0].Role)
require.Contains(t, singleText(t, messages[0]), "parent agent")
require.Equal(t, fantasy.MessageRoleAssistant, messages[1].Role)
require.Equal(t, "final reply", singleText(t, messages[1]))
require.Equal(t, fantasy.MessageRoleUser, messages[2].Role)
require.Equal(t, "Need advice", singleText(t, messages[2]))
for _, msg := range messages {
require.NotEqual(t, fantasy.MessageRoleTool, msg.Role)
}
}
func TestBuildAdvisorMessagesKeepsPairedToolCallAndResult(t *testing.T) {
t.Parallel()
snapshot := []fantasy.Message{
toolCallAssistantMessage("call-1", "search", `{"q":"x"}`),
toolResultMessage("call-1", "ok"),
textMessage(fantasy.MessageRoleAssistant, "done"),
}
messages := chatadvisor.BuildAdvisorMessages("Need advice", snapshot)
// Advisor system + assistant tool call + tool result + assistant reply
// + question. The matched pair must survive.
require.Len(t, messages, 5)
require.Equal(t, fantasy.MessageRoleSystem, messages[0].Role)
require.Equal(t, fantasy.MessageRoleAssistant, messages[1].Role)
require.Equal(t, fantasy.MessageRoleTool, messages[2].Role)
require.Equal(t, fantasy.MessageRoleAssistant, messages[3].Role)
require.Equal(t, "done", singleText(t, messages[3]))
require.Equal(t, fantasy.MessageRoleUser, messages[4].Role)
}
func streamFromParts(parts []fantasy.StreamPart) fantasy.StreamResponse {
return iter.Seq[fantasy.StreamPart](func(yield func(fantasy.StreamPart) bool) {
for _, part := range parts {
if !yield(part) {
return
}
}
})
}
func textMessage(role fantasy.MessageRole, text string) fantasy.Message {
return fantasy.Message{
Role: role,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: text},
},
}
}
func toolCallAssistantMessage(callID, name, input string) fantasy.Message {
return fantasy.Message{
Role: fantasy.MessageRoleAssistant,
Content: []fantasy.MessagePart{
fantasy.ToolCallPart{
ToolCallID: callID,
ToolName: name,
Input: input,
},
},
}
}
func toolResultMessage(callID, text string) fantasy.Message {
return fantasy.Message{
Role: fantasy.MessageRoleTool,
Content: []fantasy.MessagePart{
fantasy.ToolResultPart{
ToolCallID: callID,
Output: fantasy.ToolResultOutputContentText{Text: text},
},
},
}
}
func singleText(t *testing.T, msg fantasy.Message) string {
t.Helper()
require.NotEmpty(t, msg.Content)
text, ok := fantasy.AsMessagePart[fantasy.TextPart](msg.Content[0])
require.True(t, ok)
return text.Text
}
+142
View File
@@ -0,0 +1,142 @@
package chatadvisor
import (
"sync/atomic"
"charm.land/fantasy"
fantasyopenai "charm.land/fantasy/providers/openai"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
)
// RuntimeConfig configures a single advisor runtime instance.
type RuntimeConfig struct {
Model fantasy.LanguageModel
ModelConfig codersdk.ChatModelCallConfig
ProviderOptions fantasy.ProviderOptions
MaxUsesPerRun int
MaxOutputTokens int64
}
// Runtime executes nested, tool-less advisor runs against the configured
// language model.
//
// Each Runtime instance is scoped to a single outer chat run. The
// MaxUsesPerRun counter increments on every successful advisor call and
// is never reset, so callers must construct a fresh Runtime (via
// NewRuntime) for each outer run. There is intentionally no Reset method:
// the per-run quota is a safety bound on a single run, not a rolling
// window.
type Runtime struct {
cfg RuntimeConfig
used atomic.Int64
}
// NewRuntime validates and normalizes advisor runtime configuration.
func NewRuntime(cfg RuntimeConfig) (*Runtime, error) {
if cfg.Model == nil {
return nil, xerrors.New("advisor model is required")
}
if cfg.MaxUsesPerRun <= 0 {
return nil, xerrors.New("advisor max uses per run must be positive")
}
if cfg.MaxOutputTokens <= 0 {
return nil, xerrors.New("advisor max output tokens must be positive")
}
if cfg.ModelConfig.MaxOutputTokens != nil &&
*cfg.ModelConfig.MaxOutputTokens != cfg.MaxOutputTokens {
return nil, xerrors.Errorf(
"advisor model_config.max_output_tokens (%d) must match runtime max output tokens (%d)",
*cfg.ModelConfig.MaxOutputTokens,
cfg.MaxOutputTokens,
)
}
normalized := cfg
normalized.ProviderOptions = cloneProviderOptions(cfg.ProviderOptions)
maxOutputTokens := cfg.MaxOutputTokens
normalized.ModelConfig.MaxOutputTokens = &maxOutputTokens
return &Runtime{cfg: normalized}, nil
}
// cloneProviderOptions returns a copy of opts with pointer entries for known,
// in-place mutated provider option types replaced by a shallow struct copy.
// chatloop mutates the OpenAI Responses entry (PreviousResponseID) on
// chain-mode exit, so sharing the pointer with the parent run would let an
// advisor call corrupt the parent's chain state. Value fields such as
// Metadata and Include are still shared with the parent; nothing in this
// package mutates them, but callers that need true deep-copy semantics must
// handle those fields explicitly.
func cloneProviderOptions(opts fantasy.ProviderOptions) fantasy.ProviderOptions {
if opts == nil {
return nil
}
cloned := make(fantasy.ProviderOptions, len(opts))
for key, value := range opts {
switch typed := value.(type) {
case *fantasyopenai.ResponsesProviderOptions:
if typed == nil {
cloned[key] = value
continue
}
copied := *typed
cloned[key] = &copied
default:
cloned[key] = value
}
}
return cloned
}
// resetProviderOptionsForNestedCall strips inherited state from opts that
// does not apply to an ephemeral advisor call. PreviousResponseID is
// cleared so the nested call is not sent as a chain-mode continuation
// (BuildAdvisorMessages sends the full history, not an incremental turn).
// Store is forced off so the advisor call does not persist an orphan
// response on the provider side. Must be called on a cloned map to avoid
// mutating shared parent state.
func resetProviderOptionsForNestedCall(opts fantasy.ProviderOptions) {
for _, value := range opts {
if typed, ok := value.(*fantasyopenai.ResponsesProviderOptions); ok && typed != nil {
storeDisabled := false
typed.PreviousResponseID = nil
typed.Store = &storeDisabled
}
}
}
// RemainingUses reports how many advisor calls are still available for the
// current runtime.
func (rt *Runtime) RemainingUses() int {
if rt == nil || rt.cfg.MaxUsesPerRun <= 0 {
return 0
}
remaining := int64(rt.cfg.MaxUsesPerRun) - rt.used.Load()
if remaining < 0 {
return 0
}
return int(remaining)
}
func (rt *Runtime) tryAcquire() bool {
for {
used := rt.used.Load()
if used >= int64(rt.cfg.MaxUsesPerRun) {
return false
}
if rt.used.CompareAndSwap(used, used+1) {
return true
}
}
}
// release returns a previously acquired use to the pool. Callers must
// invoke this at most once per successful tryAcquire when the advisor
// call did not complete successfully, so a transient provider failure
// does not permanently consume quota for the run.
func (rt *Runtime) release() {
rt.used.Add(-1)
}
+60
View File
@@ -0,0 +1,60 @@
package chatadvisor
import (
"context"
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"charm.land/fantasy"
)
// advisorQuestionMaxRunes caps the parent agent's question at a length
// that leaves room in the advisor prompt for system preamble and recent
// conversation context.
const advisorQuestionMaxRunes = 2000
// ToolOptions configures the built-in advisor tool.
type ToolOptions struct {
Runtime *Runtime
GetConversationSnapshot func() []fantasy.Message
}
// Tool returns a fantasy.AgentTool that asks a nested model for concise
// strategic guidance. The nested advisor sees recent conversation
// context, runs without tools, and is limited to a single model step.
func Tool(opts ToolOptions) fantasy.AgentTool {
return fantasy.NewAgentTool(
"advisor",
"Ask a separate advisor pass for strategic guidance about planning, architecture, tradeoffs, or debugging strategy. Provide a brief question. The advisor sees recent conversation context, runs without tools for a single step, and responds to the parent agent rather than the end user.",
func(ctx context.Context, args AdvisorArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if opts.Runtime == nil {
return fantasy.NewTextErrorResponse("advisor runtime is not configured"), nil
}
if opts.GetConversationSnapshot == nil {
return fantasy.NewTextErrorResponse("conversation snapshot provider is not configured"), nil
}
question := strings.TrimSpace(args.Question)
if question == "" {
return fantasy.NewTextErrorResponse("question is required"), nil
}
if utf8.RuneCountInString(question) > advisorQuestionMaxRunes {
return fantasy.NewTextErrorResponse(
fmt.Sprintf("question must be %d runes or fewer", advisorQuestionMaxRunes),
), nil
}
result, err := opts.Runtime.RunAdvisor(ctx, question, opts.GetConversationSnapshot())
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
data, err := json.Marshal(result)
if err != nil {
return fantasy.NewTextResponse("{}"), nil
}
return fantasy.NewTextResponse(string(data)), nil
},
)
}
+266
View File
@@ -0,0 +1,266 @@
package chatadvisor_test
import (
"context"
"encoding/json"
"strings"
"testing"
"charm.land/fantasy"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/x/chatd/chatadvisor"
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
)
func TestAdvisorToolSuccess(t *testing.T) {
t.Parallel()
runtime, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Model: &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "Use the smaller diff."},
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
}), nil
},
},
MaxUsesPerRun: 2,
MaxOutputTokens: 128,
})
require.NoError(t, err)
tool := chatadvisor.Tool(chatadvisor.ToolOptions{
Runtime: runtime,
GetConversationSnapshot: func() []fantasy.Message {
return []fantasy.Message{{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "We need a safe fix."},
},
}}
},
})
resp := runAdvisorTool(t, tool, chatadvisor.AdvisorArgs{Question: "What's the safest next step?"})
require.False(t, resp.IsError)
var result chatadvisor.AdvisorResult
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
require.Equal(t, chatadvisor.ResultTypeAdvice, result.Type)
require.Equal(t, "Use the smaller diff.", result.Advice)
require.Equal(t, "test-provider/test-model", result.AdvisorModel)
require.Equal(t, 1, result.RemainingUses)
}
func TestAdvisorToolRejectsEmptyQuestion(t *testing.T) {
t.Parallel()
tool := chatadvisor.Tool(chatadvisor.ToolOptions{
Runtime: mustAdvisorRuntime(t),
GetConversationSnapshot: func() []fantasy.Message {
return nil
},
})
resp := runAdvisorTool(t, tool, chatadvisor.AdvisorArgs{Question: " \t\n "})
require.True(t, resp.IsError)
require.Contains(t, resp.Content, "question is required")
}
func TestAdvisorToolRejectsLongQuestion(t *testing.T) {
t.Parallel()
tool := chatadvisor.Tool(chatadvisor.ToolOptions{
Runtime: mustAdvisorRuntime(t),
GetConversationSnapshot: func() []fantasy.Message {
return nil
},
})
resp := runAdvisorTool(t, tool, chatadvisor.AdvisorArgs{Question: strings.Repeat("x", 2001)})
require.True(t, resp.IsError)
require.Contains(t, resp.Content, "2000 runes or fewer")
}
func TestAdvisorToolRejectsMissingRuntime(t *testing.T) {
t.Parallel()
tool := chatadvisor.Tool(chatadvisor.ToolOptions{
GetConversationSnapshot: func() []fantasy.Message {
return nil
},
})
resp := runAdvisorTool(t, tool, chatadvisor.AdvisorArgs{Question: "Need advice"})
require.True(t, resp.IsError)
require.Contains(t, resp.Content, "advisor runtime is not configured")
}
func TestAdvisorToolRejectsMissingSnapshotFunc(t *testing.T) {
t.Parallel()
tool := chatadvisor.Tool(chatadvisor.ToolOptions{Runtime: mustAdvisorRuntime(t)})
resp := runAdvisorTool(t, tool, chatadvisor.AdvisorArgs{Question: "Need advice"})
require.True(t, resp.IsError)
require.Contains(t, resp.Content, "conversation snapshot provider is not configured")
}
func TestAdvisorToolReportsNestedError(t *testing.T) {
t.Parallel()
runtime, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Model: &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return nil, xerrors.New("boom")
},
},
MaxUsesPerRun: 1,
MaxOutputTokens: 64,
})
require.NoError(t, err)
tool := chatadvisor.Tool(chatadvisor.ToolOptions{
Runtime: runtime,
GetConversationSnapshot: func() []fantasy.Message { return nil },
})
resp := runAdvisorTool(t, tool, chatadvisor.AdvisorArgs{Question: "why?"})
require.False(t, resp.IsError)
var result chatadvisor.AdvisorResult
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
require.Equal(t, chatadvisor.ResultTypeError, result.Type)
require.Contains(t, result.Error, "boom")
require.Empty(t, result.Advice)
require.Empty(t, result.AdvisorModel)
// A failed nested run does not consume the per-run quota.
require.Equal(t, 1, result.RemainingUses)
}
func TestAdvisorToolReportsLimitReached(t *testing.T) {
t.Parallel()
runtime, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Model: &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "first"},
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
}), nil
},
},
MaxUsesPerRun: 1,
MaxOutputTokens: 64,
})
require.NoError(t, err)
tool := chatadvisor.Tool(chatadvisor.ToolOptions{
Runtime: runtime,
GetConversationSnapshot: func() []fantasy.Message { return nil },
})
first := runAdvisorTool(t, tool, chatadvisor.AdvisorArgs{Question: "first?"})
require.False(t, first.IsError)
second := runAdvisorTool(t, tool, chatadvisor.AdvisorArgs{Question: "second?"})
require.False(t, second.IsError)
var result chatadvisor.AdvisorResult
require.NoError(t, json.Unmarshal([]byte(second.Content), &result))
require.Equal(t, chatadvisor.ResultTypeLimitReached, result.Type)
require.Equal(t, 0, result.RemainingUses)
require.Empty(t, result.Advice)
require.Empty(t, result.Error)
require.Empty(t, result.AdvisorModel)
}
func TestAdvisorToolReportsEmptyModelOutput(t *testing.T) {
t.Parallel()
runtime, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Model: &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
}), nil
},
},
MaxUsesPerRun: 1,
MaxOutputTokens: 64,
})
require.NoError(t, err)
tool := chatadvisor.Tool(chatadvisor.ToolOptions{
Runtime: runtime,
GetConversationSnapshot: func() []fantasy.Message { return nil },
})
resp := runAdvisorTool(t, tool, chatadvisor.AdvisorArgs{Question: "anything?"})
require.False(t, resp.IsError)
var result chatadvisor.AdvisorResult
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
require.Equal(t, chatadvisor.ResultTypeError, result.Type)
require.Contains(t, result.Error, "no text output")
require.Empty(t, result.Advice)
// An advisor call that produces no advice does not count as a
// successful use, so the quota must still be available.
require.Equal(t, 1, result.RemainingUses)
}
func mustAdvisorRuntime(t *testing.T) *chatadvisor.Runtime {
t.Helper()
runtime, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{
Model: &chattest.FakeModel{
ProviderName: "test-provider",
ModelName: "test-model",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "fallback advice"},
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
}), nil
},
},
MaxUsesPerRun: 2,
MaxOutputTokens: 64,
})
require.NoError(t, err)
return runtime
}
func runAdvisorTool(
t *testing.T,
tool fantasy.AgentTool,
args chatadvisor.AdvisorArgs,
) fantasy.ToolResponse {
t.Helper()
data, err := json.Marshal(args)
require.NoError(t, err)
resp, err := tool.Run(t.Context(), fantasy.ToolCall{
ID: "call-1",
Name: "advisor",
Input: string(data),
})
require.NoError(t, err)
return resp
}
+28
View File
@@ -0,0 +1,28 @@
package chatadvisor
// ResultType is the tagged variant of AdvisorResult. Callers should
// compare against the exported constants rather than string literals.
type ResultType string
const (
// ResultTypeAdvice indicates the advisor returned guidance.
ResultTypeAdvice ResultType = "advice"
// ResultTypeLimitReached indicates the per-run advisor budget is exhausted.
ResultTypeLimitReached ResultType = "limit_reached"
// ResultTypeError indicates the nested advisor run failed.
ResultTypeError ResultType = "error"
)
// AdvisorArgs contains the tool-visible advisor question.
type AdvisorArgs struct {
Question string `json:"question"`
}
// AdvisorResult is the structured result returned by the advisor runtime.
type AdvisorResult struct {
Type ResultType `json:"type"`
Advice string `json:"advice,omitempty"`
Error string `json:"error,omitempty"`
AdvisorModel string `json:"advisor_model,omitempty"`
RemainingUses int `json:"remaining_uses"`
}