Skip to content

Commit 98aa1a3

Browse files
adamdougalCopilot
andauthored
feat: drive interactive skills via an LLM responder (#303) (#304)
* docs: add responder (interactive skills) design spec #303 Adds the approved design for an LLM-backed surrogate user that answers a skill's follow-up questions per task under inputs.responder, with reply/stop/abstain classification, a runner-driven follow-up loop reusing the agent session, and distinct result tagging for abstain (StatusError) and cap-exhaustion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add responder (interactive skills) implementation plan #303 Bite-sized TDD task breakdown covering the inputs.responder config model and validation, the internal/responder package (persistent surrogate-user session with reply/stop/abstain classification), the runner-driven follow-up loop, ResponderInfo reporting, JSON schema, docs, and dashboard surfacing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add inputs.responder config model #303 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: validate inputs.responder fields and mutual exclusivity #303 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add responder decision types and tools #303 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add responder Classifier with persistent session #303 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add ResponderInfo to RunResult #303 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: add injectable responder classifier factory to runner #303 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: drive interactive skills via responder loop #303 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use persistent responder session with explicit teardown #303 Responder Classify used EphemeralSession=true, which the engine deletes after the first turn, breaking session resume and dropping instructions on every subsequent turn. Switch to a persistent (non-ephemeral) session, add Classifier.Close plus CopilotEngine.DeleteSession to tear it down explicitly, and call Close via defer at the end of the responder loop with a detached context so cleanup runs even on cancellation. Capture sessionID before the error check so an error-with-decision still persists the session id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add inputs.responder to task JSON schema #303 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: document inputs.responder for interactive skills #303 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: surface responder outcome in dashboard #303 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: rebuild dashboard bundle and fix lint misspelling #303 Rebuild web/dist/index.html so its asset hash matches the freshly built bundle (fixes TestIndexHTMLReferencesExistingAssets after the responder dashboard change) and correct a misspelling flagged by golangci-lint in the responder cleanup comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: free responder session usage collector on DeleteSession #303 A non-ephemeral session registers in both e.sessions and e.usageCollectors, but DeleteSession only removed it from e.sessions, orphaning the usage collector for the engine's lifetime. Each responder-driven task leaked one collector; under concurrent runs this accumulated monotonically. Also delete the usageCollectors entry under its mutex. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: remove implementation plan * fix(responder): surface tool errors and drop dead cap-exhausted flag Addresses three review comments on PR #304: * Reject duplicate decision tool calls in the same turn instead of letting handler order silently pick the winner. The recorder now returns an error on the second call and Classify surfaces it. * Propagate mapstructure decode failures from each tool handler so malformed arguments become a 'responder tool call invalid' error rather than a fabricated empty reply/abstain. * Drop the unused lastWasReply flag and the dead initial ResponderOutcomeCompleted seed in the responder loop. The loop can only exit normally after a reply, so the post-loop branch unconditionally records cap_exhausted. Removed the now-unused ResponderOutcomeCompleted constant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(responder): synchronize decision recorder against concurrent tool calls #303 The Copilot SDK dispatches each tool call on its own goroutine, so parallel decision calls in one turn raced on the recorder's set/decision/err fields and the previous guardDuplicate check was a non-atomic read-then-act. Guard all fields with a sync.Mutex and route every handler through atomic record/fail methods so the duplicate check-and-set cannot interleave. Adds TestDecisionToolsConcurrentCallsRecordOne, which fires all three tools from goroutines and asserts exactly one decision wins, passing under go test -race. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(responder): drop stale completed value from ResponderInfo.Outcome comment #303 ResponderOutcomeCompleted was removed earlier in this work, so listing completed as a possible Outcome is misleading; the field is now documented as one of stopped, abstained, cap_exhausted, error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(schema): make inputs.responder and inputs.follow_up_prompts mutually exclusive #303 follow_up_prompts was defined at the schema root rather than under inputs, so with additionalProperties false the correct inputs.follow_up_prompts placement was being rejected; move it into the inputs object and add a not constraint forbidding responder and follow_up_prompts together so the schema mirrors the runtime Validate contract and editors warn before run time. Adds TestValidateTaskBytes_FollowUpPrompts and TestValidateTaskBytes_ResponderAndFollowUpsMutuallyExclusive. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(execution): delete remote responder session before dropping local tracking #303 DeleteSession removed the session from e.sessions and e.usageCollectors before issuing the remote delete, so a failed remote call left the session untracked, leaking it and losing usage collection. Issue the remote delete first and only drop local tracking on success; on failure the error surfaces and the session stays registered for shutdown cleanup. Adds TestCopilotEngine_DeleteSession_PropagatesRemoteError covering the empty-id no-op, remote-error, and success paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(responder): reword cap-exhaustion log to not assert agent intent #303 At cap exhaustion we cannot know whether the agent would have asked again, so the previous message claiming the agent was still asking questions was misleading. Reword the warning and comment to state that the reply budget was exhausted before the responder signaled stop or abstain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(web): type responder outcome as a string-literal union #303 ResponderInfo.outcome was typed as string, losing exhaustiveness checking in the dashboard. Introduce a ResponderOutcome union (stopped, abstained, cap_exhausted, error) so rendering and styling stay type-safe as outcomes evolve. Rebuilds the embedded dashboard bundle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(responder): reject empty reply and abstain-reason tool arguments #303 mapstructure decodes a missing or blank answer/reason to an empty string, so the responder could send the agent an empty reply or record a reasonless abstain even though both tool schemas mark the field required. Treat a whitespace-only answer or reason as a handler failure via d.fail so Classify surfaces a clear error instead of fabricating a blank decision. Adds TestDecisionToolsRejectEmptyReply and TestDecisionToolsRejectEmptyAbstainReason covering the missing and blank cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7b36ee8 commit 98aa1a3

22 files changed

Lines changed: 1655 additions & 12 deletions

File tree

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,34 @@ Waza automatically removes each worktree on engine shutdown (`git worktree remov
12071207

12081208
**Out of scope today** (tracked separately): HTTPS / SSH clone strategies, submodules, Git LFS, and auto-detecting "the repo this test is running in" without an explicit `source`.
12091209

1210+
## Responder (interactive skills)
1211+
1212+
For skills that ask follow-up questions, configure a `responder` — an LLM that plays the user and answers the skill's questions. It is mutually exclusive with `follow_up_prompts`.
1213+
1214+
```yaml
1215+
# task.yaml
1216+
inputs:
1217+
prompt: "Add a new agent to my application"
1218+
responder:
1219+
model: gpt-4o # optional; defaults to config.model
1220+
instructions: |
1221+
The agent you want is "research-agent" with system instructions
1222+
"Search the web and summarise findings", tools web_search + url_fetch,
1223+
and no handoffs. Answer the skill's questions consistently with this.
1224+
If you genuinely can't infer an answer, abstain.
1225+
max_followups: 8
1226+
```
1227+
1228+
After each agent turn the responder either **replies** (the answer is sent back, continuing the conversation), **stops** (the agent is done), or **abstains** — which fails the run with a distinct `abstained` outcome, signalling the brief is too vague. If `max_followups` is reached while the agent is still asking questions, the loop stops with outcome `cap_exhausted` and graders evaluate the final state. Each task carries its own responder, so the same skill can be tested against several target configurations.
1229+
1230+
**Fields** (under `inputs.responder`):
1231+
1232+
| Field | Required | Description |
1233+
|---|---|---|
1234+
| `instructions` | yes | The target configuration the responder represents and the rule for abstaining. |
1235+
| `max_followups` | yes | Maximum number of responder replies before the loop stops (`>= 1`). |
1236+
| `model` | no | Model used for the responder LLM. Defaults to the eval-level `config.model`. |
1237+
12101238
## CI/CD Integration
12111239

12121240
Waza is designed to work seamlessly with CI/CD pipelines.
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
# Design: Responder — driving interactive skills via a surrogate LLM
2+
3+
- **Issue:** [#303](https://github.com/microsoft/waza/issues/303)
4+
- **Status:** Approved design, ready for implementation planning
5+
- **Date:** 2026-05-29
6+
7+
## Problem
8+
9+
A growing category of Agent Skills is inherently multi-turn: when the agent
10+
doesn't have everything it needs from the initial prompt, it pauses to ask the
11+
user follow-up questions before completing the task.
12+
13+
**Concrete example — `configure-agent`.** Invoked with "Add a new agent to my
14+
application", the skill must gather a name, system instructions, the set of
15+
tools, and any handoffs before it can generate the agent definition. None of
16+
these can be inferred from the initial prompt, and the structured Q&A *is* part
17+
of the skill's value.
18+
19+
Today, evaluating such a skill in waza forces a bad trade-off: either pre-bake
20+
every answer into the initial prompt (collapsing the skill into a degenerate
21+
one-shot version that no longer tests what ships) or evaluate manually. The
22+
existing static `follow_up_prompts` mechanism only works when the questions —
23+
and their order — are known in advance, which defeats the purpose of testing the
24+
skill's adaptive questioning.
25+
26+
## Goal
27+
28+
Add a **responder**: an LLM-backed surrogate user, configured per task, that
29+
answers the skill's follow-up questions consistently with a described target
30+
configuration. After each agent turn produces chat text, the responder
31+
classifies it into one of three outcomes:
32+
33+
- **Reply** → send the responder's answer back as a new user prompt, decrement a
34+
follow-up budget, and continue.
35+
- **Stop** → the agent is done (no further questions); exit the loop cleanly.
36+
- **Abstain** → the responder explicitly could not answer. Abort the run with a
37+
distinct failure classification, signalling that the brief/skill is too vague
38+
*not* a transient model timeout or network blip.
39+
40+
## Scope
41+
42+
### In scope
43+
44+
- Per-task `responder` config under `inputs` (sibling to `follow_up_prompts`).
45+
- A responder component that maintains a persistent surrogate-user session and
46+
classifies each agent message via structured tool-calling.
47+
- Runner integration: a responder-driven follow-up loop reusing the existing
48+
agent session and workspace.
49+
- Distinct result tagging for abstain and cap-exhaustion, surfaced in logs,
50+
results JSON, and the dashboard.
51+
- Schema, validation, tests, and documentation.
52+
53+
### Out of scope (possible follow-ups)
54+
55+
- Eval-level responder defaults shared across tasks (each task is self-contained
56+
for now; if many tasks share one target config, the block repeats).
57+
- Per-field override/merge semantics between eval-level and task-level config.
58+
59+
## User-facing surface
60+
61+
The responder is configured per task under `inputs`, alongside the existing
62+
`follow_up_prompts` field. The two are mutually exclusive.
63+
64+
```yaml
65+
inputs:
66+
prompt: "Add a new agent to my application"
67+
responder:
68+
model: gpt-4o # optional; defaults to config.model
69+
instructions: |
70+
You are configuring a new agent inside an agentic application.
71+
The agent you want to create has:
72+
- name: research-agent
73+
- system instructions: "Search the web and summarise findings on the
74+
topic the user provides."
75+
- tools: web_search, url_fetch
76+
- handoffs: none
77+
Answer the skill's questions consistently with this configuration,
78+
regardless of the order in which the skill asks for each piece.
79+
If you genuinely can't infer an answer from the above, abstain.
80+
max_followups: 8
81+
```
82+
83+
Because the responder lives per task, the same skill can be exercised against
84+
several target configurations (a research agent, a customer-support agent, a
85+
triage agent with handoffs) by giving each task its own `responder.instructions`
86+
— this is exactly the robustness testing the issue calls for, achieved without
87+
any eval-level override machinery.
88+
89+
### Configuration fields
90+
91+
| Field | Required | Default | Notes |
92+
|----------------|----------|------------------|-----------------------------------------|
93+
| `model` | no | `config.model` | Model used for the responder LLM. |
94+
| `instructions` | yes | — | Describes the target config + abstain rule. |
95+
| `max_followups`| yes | — | Must be `>= 1`. Caps responder replies. |
96+
97+
## Architecture
98+
99+
The design reuses two patterns already proven in the codebase:
100+
101+
1. **LLM-backed classification via structured tool-calling**, as used by the
102+
prompt grader (`internal/graders/prompt_grader.go`), against the narrow
103+
`Executor` interface (`Execute(ctx, *ExecutionRequest)`).
104+
2. **Multi-turn agent follow-ups via session + workspace reuse**, as used by the
105+
existing static follow-up loop (`executeFollowUps` in
106+
`internal/orchestration/runner.go`), which resumes the agent session by
107+
passing `SessionID` and `WorkspaceDir` on each `Execute`.
108+
109+
The responder owns classification; the runner owns the loop and all agent
110+
follow-up plumbing (per-turn timeout, event/usage/tool-call merging).
111+
112+
```mermaid
113+
sequenceDiagram
114+
participant R as Runner (executeResponderLoop)
115+
participant A as Agent session (engine)
116+
participant C as Responder Classifier
117+
participant S as Responder session (engine)
118+
119+
R->>A: initial prompt (Execute)
120+
A-->>R: agent chat text (FinalOutput)
121+
loop while budget > 0
122+
R->>C: Classify(agentMessage)
123+
C->>S: agent question (Execute, persistent session + decision tools)
124+
S-->>C: tool call: respond / stop / abstain
125+
C-->>R: Decision
126+
alt reply
127+
R->>A: follow-up = answer (Execute, reuse SessionID + WorkspaceDir)
128+
A-->>R: agent chat text
129+
Note over R: budget--
130+
else stop
131+
Note over R: outcome = stopped; break
132+
else abstain
133+
Note over R: outcome = abstained (StatusError); break
134+
end
135+
end
136+
Note over R: budget exhausted while still replying → outcome = cap_exhausted
137+
R->>R: run graders against final state
138+
```
139+
140+
### Component 1: Config model (`internal/models`)
141+
142+
A new `ResponderConfig` carried on `TaskStimulus` (the `inputs` block):
143+
144+
```go
145+
type ResponderConfig struct {
146+
Model string `yaml:"model,omitempty" json:"model,omitempty"`
147+
Instructions string `yaml:"instructions" json:"instructions"`
148+
MaxFollowups int `yaml:"max_followups" json:"max_followups"`
149+
}
150+
151+
// TaskStimulus gains:
152+
// Responder *ResponderConfig `yaml:"responder,omitempty" json:"responder,omitempty"`
153+
```
154+
155+
**Validation** (in `TestCase.Validate`, surfaced by `LoadTestCase`):
156+
157+
- If `Responder != nil`:
158+
- `Instructions` must be non-empty.
159+
- `MaxFollowups >= 1`.
160+
- `FollowUps` must be empty (mutual exclusivity; clear error message naming
161+
both fields).
162+
163+
### Component 2: Responder package (`internal/responder`)
164+
165+
```go
166+
type DecisionKind int
167+
const (
168+
DecisionReply DecisionKind = iota
169+
DecisionStop
170+
DecisionAbstain
171+
)
172+
173+
type Decision struct {
174+
Kind DecisionKind
175+
Answer string // set when Kind == DecisionReply
176+
Reason string // set when Kind == DecisionAbstain
177+
}
178+
179+
// Executor is the narrow execution surface the responder needs (same shape as
180+
// graders.Executor), enabling unit tests with a fake executor.
181+
type Executor interface {
182+
Execute(ctx context.Context, req *execution.ExecutionRequest) (*execution.ExecutionResponse, error)
183+
}
184+
185+
type Classifier struct {
186+
exec Executor
187+
model string
188+
instructions string
189+
sessionID string // empty until the first Classify creates the session
190+
}
191+
192+
func New(exec Executor, cfg models.ResponderConfig, defaultModel string) *Classifier
193+
func (c *Classifier) Classify(ctx context.Context, agentMessage string) (Decision, error)
194+
```
195+
196+
`Classify` behaviour:
197+
198+
- **Persistent session.** The first call creates the responder session (no
199+
resume `SessionID`); the returned `SessionID` is stored and passed on every
200+
subsequent call so the responder accumulates the back-and-forth like a real
201+
user. The session is owned by the engine and cleaned up at `Shutdown`.
202+
- **First message** carries the responder `instructions` as a preamble plus the
203+
agent's first question and a directive to answer by calling exactly one
204+
decision tool. **Later messages** carry only the agent's latest question
205+
(instructions persist in session context).
206+
- **Structured output** via three tools whose handlers capture the decision:
207+
- `respond(answer: string)` → `DecisionReply`
208+
- `stop()` → `DecisionStop`
209+
- `abstain(reason: string)` → `DecisionAbstain`
210+
- Request uses `NoSkills: true`, `MessageMode: MessageModeEnqueue`,
211+
`Streaming: true`. The responder session does **not** use the agent's
212+
workspace.
213+
- If no decision tool is called (responder malfunction), `Classify` returns an
214+
error — distinct from abstain.
215+
216+
### Component 3: Runner loop (`internal/orchestration/runner.go`)
217+
218+
In `executeRun`, after the initial `Execute`:
219+
220+
- If `tc.Stimulus.Responder != nil` → `executeResponderLoop`.
221+
- Else if `len(tc.Stimulus.FollowUps) > 0` → existing `executeFollowUps`.
222+
223+
`executeResponderLoop` mirrors `executeFollowUps` plumbing (build request via
224+
`buildExecutionRequest`, set `Message`/`SessionID`/`WorkspaceDir`, apply per-turn
225+
timeout, merge `Events`/`ToolCalls`/`SkillInvocations`/`DurationMs`/`FinalOutput`/
226+
`WorkspaceFiles`/`Usage` into `resp`). Pseudocode:
227+
228+
```
229+
classifier := responder.New(r.engine, *tc.Stimulus.Responder, r.spec.Config.ModelID)
230+
left := tc.Stimulus.Responder.MaxFollowups
231+
sent := 0
232+
outcome := "completed"
233+
for left > 0 {
234+
decision, err := classifier.Classify(ctx, resp.FinalOutput)
235+
if err != nil { resp.ErrorMsg = "responder error: " + err; outcome = "error"; break }
236+
switch decision.Kind {
237+
case Reply:
238+
// send agent follow-up using decision.Answer (reuse SessionID + WorkspaceDir)
239+
// merge follow-up response into resp; on error set resp.ErrorMsg + break
240+
sent++; left--
241+
log: responder replied (turn sent, budget left)
242+
case Stop:
243+
outcome = "stopped"; goto done
244+
case Abstain:
245+
resp.ErrorMsg = "responder abstained: " + decision.Reason
246+
outcome = "abstained"; goto done
247+
}
248+
}
249+
if left == 0 && lastDecisionWasReply {
250+
outcome = "cap_exhausted"
251+
log warning: responder budget exhausted while agent still asking
252+
}
253+
done:
254+
attach ResponderInfo{FollowupsSent: sent, Outcome: outcome, Reason: ...} to the run
255+
```
256+
257+
Verbose mode emits per-turn progress events (reusing the existing
258+
`EventAgentPrompt` / `EventAgentResponse` style) so `-v` runs show the
259+
responder's answers and the agent's replies.
260+
261+
### Component 4: Results & reporting (`internal/models/outcome.go`)
262+
263+
```go
264+
type ResponderInfo struct {
265+
FollowupsSent int `json:"followups_sent"`
266+
Outcome string `json:"outcome"` // completed|stopped|abstained|cap_exhausted|error
267+
Reason string `json:"reason,omitempty"`
268+
}
269+
270+
// RunResult gains:
271+
// Responder *ResponderInfo `json:"responder,omitempty"`
272+
```
273+
274+
Status mapping:
275+
276+
| Responder outcome | `RunResult.Status` | `ErrorMsg` | Notes |
277+
|-------------------|--------------------|------------------------------------|-------|
278+
| `completed` | unchanged (graded) || Agent finished; graders decide pass/fail. |
279+
| `stopped` | unchanged (graded) || Responder signalled done. |
280+
| `abstained` | `StatusError` | `responder abstained: <reason>` | Distinct, filterable; separate from timeouts/network errors. |
281+
| `cap_exhausted` | unchanged (graded) || Logged + surfaced; graders judge the end state. |
282+
| `error` | `StatusError` | `responder error: <msg>` | Responder malfunction (no decision / session failure). |
283+
284+
Because abstain reuses `StatusError` but is tagged via `Responder.Outcome`,
285+
reports and the dashboard can distinguish a vague-brief abstain from a genuine
286+
error. The dashboard (`web/`) surfaces `responder.outcome` (and reason) so
287+
abstain and cap-exhaustion are visible per run.
288+
289+
## Error handling & edge cases
290+
291+
- **No decision tool called**`Classify` error → run `error` outcome
292+
(`StatusError`), distinct from abstain.
293+
- **Responder session creation/Execute failure** → propagated as run `error`.
294+
- **Agent follow-up Execute failure** → mirrors `executeFollowUps`: set
295+
`resp.ErrorMsg`, stop the loop.
296+
- **`max_followups` exhausted while agent still asking**`cap_exhausted`; loop
297+
stops, run proceeds to grading, warning logged.
298+
- **Mutual exclusivity** of `responder` and `follow_up_prompts` enforced at load
299+
time with a clear error.
300+
- **Context cancellation / task timeout** honoured on every responder and agent
301+
turn via the existing per-turn timeout pattern.
302+
303+
## Testing
304+
305+
- **`internal/responder`** — fake `Executor` invoking decision-tool handlers:
306+
reply / stop / abstain / no-decision-error; persistent-session resumption
307+
(second `Classify` passes the stored `SessionID`); first-vs-later message
308+
shape (instructions preamble only on first call); model defaulting.
309+
- **`internal/orchestration`** — mock engine + injectable classifier (or fake
310+
executor): reply → agent follow-up sent with reused session/workspace; stop;
311+
abstain → `StatusError` + `Responder.Outcome == "abstained"`; cap exhaustion →
312+
graded + `Responder.Outcome == "cap_exhausted"`; mutual-exclusivity rejection.
313+
- **`internal/models`** — validation: missing instructions, `max_followups < 1`,
314+
both `responder` and `follow_up_prompts` set.
315+
- **Schema**`internal/validation` and `internal/projectconfig` parity tests
316+
for the new `responder` field.
317+
- All existing tests remain green; `go test ./...` and `golangci-lint run` pass.
318+
319+
## Documentation
320+
321+
Per `AGENTS.md`:
322+
323+
- `README.md` — responder section + YAML example in the eval/inputs docs.
324+
- `site/src/content/docs/` — eval-YAML reference entry for `inputs.responder`
325+
and a short guide on testing interactive skills; build with `npm run build`.
326+
- Schema files kept in sync.
327+
- Dashboard (`web/`) — surface `responder.outcome`/`reason`; regenerate
328+
screenshots if UI changes.
329+
- Reference issue #303 in commits; update tracking issue #66 if applicable.
330+
331+
## Rationale
332+
333+
- **Per-task placement** mirrors `follow_up_prompts`, keeps each task
334+
self-contained, makes mutual-exclusivity checking local, and directly serves
335+
the "vary the target config across tasks" use case — without any eval-level
336+
override/merge complexity.
337+
- **Runner owns the loop, responder owns classification** keeps the responder
338+
small and unit-testable, and reuses the battle-tested agent follow-up plumbing
339+
rather than duplicating it.
340+
- **Persistent responder session** models a real user who remembers prior
341+
answers, avoiding contradictory or repeated responses across turns.
342+
- **Abstain as tagged `StatusError`** satisfies the issue's requirement that a
343+
vague-brief abstain be reportable separately from transient errors, without
344+
introducing a new top-level status value that every report/consumer would need
345+
to learn.

0 commit comments

Comments
 (0)