Skip to content

Commit 8cf8b61

Browse files
anticomputerCopilot
andcommitted
Add capture: response to store an agent's final response text as output
Tasks can set `capture: response` to publish the agent's final response text (the prose after its last tool call) as their named `outputs.<id>` instead of their final tool result. This applies uniformly to a plain task's scalar output and to each branch's result in a fan-out task's fan-in list, and composes with the `outputs` JSON Schema. The default, `capture: tool_result`, is unchanged. The stream driver accumulates response text and resets it on each tool call, then forwards the final segment through a new record_message sink threaded from deploy_task_agents; the capture layer wraps it as a synthetic tool result so response and tool-result capture share one decode/validate path. Response capture is rejected on shell tasks. Add an example_model_comparison taskflow that fans a question across models, captures each model's answer, and has a judge task compare them, with runnable walkthrough docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 729de1b commit 8cf8b61

9 files changed

Lines changed: 355 additions & 22 deletions

File tree

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,28 @@ The framework includes a [CodeQL](https://codeql.github.com/) MCP server that ca
237237

238238
Instead of generating CodeQL queries itself, the CodeQL MCP Server is used to provide CodeQL-query based MCP tools that allow an Agent to navigate and explore code. It leverages templated CodeQL queries to provide targeted context for model driven code analysis.
239239

240+
### Model comparison and evaluation
241+
242+
The framework can also run the same prompt across several models and compare their answers, which is useful for evaluating models against each other on a given task. The [example_model_comparison](examples/taskflows/example_model_comparison.yaml) taskflow shows the pattern: a multi-model task fans the same question out across two models, captures each model's prose answer with `capture: response`, and a second "judge" task reads every captured answer by name (`outputs.answers`) and picks the best one.
243+
244+
Run it against your endpoint like any other taskflow:
245+
246+
```bash
247+
python -m seclab_taskflow_agent -t examples.taskflows.example_model_comparison
248+
```
249+
250+
Each model answers in its own labelled output block, and the judge task then compares them, for example:
251+
252+
```
253+
** 🤖✏️ Output for gpt_alt
254+
A use-after-free bug is a type of software vulnerability that occurs when a program continues to use a memory location after it has been freed ...
255+
** 🤖✏️ Output for gpt_fast
256+
A use-after-free bug occurs when a program continues to use memory after it has been freed ...
257+
The gpt_fast answer is best because it is more concise while still clearly explaining ...
258+
```
259+
260+
The two models are defined in [multi_model.yaml](examples/model_configs/multi_model.yaml); swap the provider IDs there to compare whatever models your endpoint exposes. `capture: response` is what makes the models' prose answers (rather than a tool result) available to the judge; see the "Capturing the response instead of a tool result" section of [doc/GRAMMAR.md](doc/GRAMMAR.md) for details.
261+
240262
## Requirements
241263

242264
Python >= 3.10 or Docker

doc/GRAMMAR.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,43 @@ Notes:
488488
task does not feed it: there is no single "last" result across models, so a
489489
downstream task must consume its output by name via `id`/`over`.
490490

491+
### Capturing the response instead of a tool result (`capture`)
492+
493+
By default a task's named output (`outputs.<id>`) is its final **tool result**.
494+
Some tasks have no meaningful tool result to capture: an evaluation or
495+
comparison flow just wants the model's **prose answer**. Set `capture: response`
496+
to store the agent's final response text instead.
497+
498+
- `capture: tool_result` (default) captures the task's last tool result, as
499+
described above.
500+
- `capture: response` captures the agent's final response text: the prose it
501+
emits after its last tool call (or the whole answer when it calls no tools).
502+
This applies uniformly to the scalar output of a plain task and to each
503+
branch's `result` in a fan-out task's fan-in list.
504+
505+
`capture` composes with everything else: an `outputs` schema still validates the
506+
captured value (the response text is decoded as JSON first, so a schema-typed
507+
response task must emit JSON), and `id` still names it for later tasks. It does
508+
not apply to shell (`run`) tasks, which have no agent response.
509+
510+
```yaml
511+
- task:
512+
# ask several models the same question; capture each prose answer
513+
id: answers
514+
models: [gpt_fast, gpt_alt]
515+
capture: response
516+
agents: [seclab_taskflow_agent.personalities.assistant]
517+
user_prompt: "In one sentence, what is a use-after-free bug?"
518+
- task:
519+
# a judge model reads every captured answer by name
520+
agents: [seclab_taskflow_agent.personalities.assistant]
521+
user_prompt: |
522+
Rank these answers and explain your pick:
523+
{% for a in outputs.answers %}
524+
- {{ a.model }}: {{ a.result }}
525+
{% endfor %}
526+
```
527+
491528
### Toolboxes / MCP Servers
492529

493530
Toolboxes are MCP server configurations. They can be defined at the Agent level or overridden at the task level. These MCP servers are started and made available to the Agents in the Agents list during a Task. The `toolboxes` field should contain a list of files for the `toolboxes` that are available for the task:
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
4+
# Example: a simple model evaluation / comparison flow.
5+
# 1. Ask several models the SAME question in parallel and capture each
6+
# model's prose answer (capture: response).
7+
# 2. A single "judge" model reads every captured answer by name and picks
8+
# the best one.
9+
# This showcases multi-model fan-out, response capture, fan-in via
10+
# outputs.<id>, and an `if` gate. See doc/GRAMMAR.md "Multiple Models",
11+
# "Typed named outputs", and "Capturing the response instead of a tool result".
12+
13+
seclab-taskflow-agent:
14+
version: "1.0"
15+
filetype: taskflow
16+
17+
model_config: examples.model_configs.multi_model
18+
19+
globals:
20+
question: "In one sentence, what is a use-after-free bug?"
21+
22+
taskflow:
23+
# Fan out the same question across both models. capture: response stores each
24+
# model's final prose answer, so outputs.answers becomes a fan-in list of
25+
# {model, item, result} records -- one per model -- ready to compare.
26+
- task:
27+
id: answers
28+
models: [gpt_fast, gpt_alt]
29+
completion: all
30+
capture: response
31+
agents:
32+
- seclab_taskflow_agent.personalities.assistant
33+
user_prompt: "{{ globals.question }}"
34+
35+
# A single judge model compares the captured answers by name. Runs only if at
36+
# least one model answered.
37+
- task:
38+
if: "outputs.answers | length > 0"
39+
agents:
40+
- seclab_taskflow_agent.personalities.assistant
41+
user_prompt: |
42+
You are judging candidate answers to this question:
43+
"{{ globals.question }}"
44+
45+
The candidates, one per model:
46+
{% for a in outputs.answers %}
47+
- {{ a.model }}: {{ a.result }}
48+
{% endfor %}
49+
50+
In two sentences, say which answer is best and why.

src/seclab_taskflow_agent/_stream.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ async def drive_backend_stream(
7474
max_rate_limit_backoff: int,
7575
record_tool_result: Any = None,
7676
record_usage: Any = None,
77+
record_message: Any = None,
7778
) -> None:
7879
"""Run the backend's event stream to completion with retry/backoff.
7980
@@ -85,12 +86,24 @@ async def drive_backend_stream(
8586
:class:`BackendTimeoutError`, and applies exponential backoff up to
8687
*max_rate_limit_backoff* seconds on :class:`BackendRateLimitError`
8788
before giving up with a :class:`BackendTimeoutError`.
89+
90+
When *record_message* is provided, the agent's final response text is
91+
forwarded to it once the stream completes successfully. "Final response"
92+
is the prose emitted after the last tool call: text deltas are accumulated
93+
and reset on each ``ToolEnd``, so a task that ends on a tool call yields
94+
only that trailing summary, and a plain question-and-answer turn yields the
95+
whole answer. This is what ``capture: response`` tasks store as their named
96+
output.
8897
"""
8998
max_retry = max_api_retry
9099
rate_limit_backoff = initial_rate_limit_backoff
91100
last_rate_limit_exc: BackendRateLimitError | None = None
92101

93102
while rate_limit_backoff:
103+
# Accumulate the agent's final response text per attempt (reset on a
104+
# retry, and on each tool call, so we keep only the prose after the
105+
# last tool result -- the model's final answer).
106+
final_message_parts: list[str] = []
94107
try:
95108
stream = backend_impl.run_streamed(
96109
agent_handle, prompt, max_turns=max_turns
@@ -110,10 +123,12 @@ async def drive_backend_stream(
110123
) from exc
111124
watchdog_ping()
112125
if isinstance(event, TextDelta):
126+
final_message_parts.append(event.text)
113127
await render_model_output(
114128
event.text, async_task=async_task, task_id=task_id
115129
)
116130
elif isinstance(event, ToolEnd):
131+
final_message_parts.clear()
117132
await handle_tool_end_event(event, run_hooks, record_tool_result)
118133
elif isinstance(event, TokenUsage):
119134
logging.info(
@@ -138,6 +153,8 @@ async def drive_backend_stream(
138153
except Exception: # noqa: BLE001 - best-effort cleanup
139154
logging.exception("Failed to aclose backend stream iterator")
140155
await render_model_output("\n\n", async_task=async_task, task_id=task_id)
156+
if record_message is not None:
157+
await record_message("".join(final_message_parts))
141158
return
142159
except BackendTimeoutError:
143160
if not max_retry:

src/seclab_taskflow_agent/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@
4343
# single branch succeeding is enough.
4444
CompletionPolicy = Literal["all", "any"]
4545

46+
# Which produced value a task captures as its named ``outputs.<id>``:
47+
# ``tool_result`` (default) captures the task's final tool result; ``response``
48+
# captures the agent's final response text (the prose after its last tool
49+
# call), which is what model-comparison / evaluation flows want.
50+
CaptureSource = Literal["tool_result", "response"]
51+
4652

4753
# ---------------------------------------------------------------------------
4854
# Header
@@ -125,6 +131,11 @@ class TaskDefinition(BaseModel):
125131
# value; when set, the captured output is validated and exposed to
126132
# later tasks as ``outputs.<id>``.
127133
outputs: dict[str, Any] = Field(default_factory=dict)
134+
# Which produced value feeds ``outputs.<id>``: the task's final tool result
135+
# (default) or the agent's final response text (``response``). Response
136+
# capture is what side-by-side model comparison / evaluation flows want,
137+
# since the value to compare is the model's prose answer, not a tool result.
138+
capture: CaptureSource = "tool_result"
128139
# Explicit iterable selector for repeat_prompt: a Jinja expression
129140
# evaluated against the template context (e.g. ``outputs.list_fns.items``).
130141
over: str = ""
@@ -183,6 +194,11 @@ def _coerce_models(cls, v: Any) -> list[Any]:
183194
def _run_xor_prompt(self) -> TaskDefinition:
184195
if self.run and self.user_prompt:
185196
raise ValueError("shell task ('run') and prompt task ('user_prompt') are mutually exclusive")
197+
if self.capture == "response" and self.run:
198+
raise ValueError(
199+
"capture: response captures an agent's final response text and does not "
200+
"apply to a shell task ('run'); use the default capture: tool_result"
201+
)
186202
return self
187203

188204
@model_validator(mode="after")

0 commit comments

Comments
 (0)