Skip to content

Commit 8de19ce

Browse files
authored
Merge pull request #272 from GitHubSecurityLab/anticomputer/grammar-maturity
feat: mature the taskflow grammar (multi-model, typed outputs, conditionals, tooling, observability)
2 parents dcbb4f5 + cd2ec38 commit 8de19ce

50 files changed

Lines changed: 5131 additions & 299 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,6 @@ config.yaml
183183

184184
#database
185185
*.db
186+
187+
# memcache dictionary_file state written by example taskflows at runtime
188+
*_taskflow/

README.md

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ You can find a detailed overview of the taskflow grammar [here](doc/GRAMMAR.md)
2424
```
2525
┌─────────────────────────────────────────────────────┐
2626
│ CLI (cli.py) │
27-
│ Typer-based entry point: -p, -t, -l, -g, -m, --resume│
27+
│ Typer-based entry point: -p, -t, -l, -g, -m, --resume, --lint
2828
└─────────────────────┬───────────────────────────────┘
2929
3030
┌─────────────────────▼───────────────────────────────┐
@@ -160,6 +160,20 @@ Failed tasks are automatically retried up to 3 times with increasing backoff
160160
before the session is saved. Session checkpoints are stored in the
161161
platform-specific application data directory.
162162

163+
### Run Manifest
164+
165+
Every run produces a machine-readable manifest summarising what happened:
166+
per-task status (ok / failed / skipped), the models each task ran against,
167+
timing, and the named `outputs` each task produced (including per-model fan-in
168+
records for multi-model tasks). It contains no endpoints or tokens.
169+
170+
The manifest is written to a run-scoped artifacts directory when a run finishes
171+
or fails, and can be printed for any session by ID:
172+
173+
```bash
174+
python -m seclab_taskflow_agent --manifest abc123def456
175+
```
176+
163177
### Error Output
164178

165179
By default, errors are shown as concise one-line messages. Use `--debug` (or
@@ -174,6 +188,30 @@ Error: [BadRequestError] model 'foo' not found
174188
python -m seclab_taskflow_agent --debug -t examples.taskflows.echo
175189
```
176190

191+
### Linting and Schema
192+
193+
Taskflows can be validated offline, without making any model calls, using
194+
`--lint`. This resolves the taskflow and every document it references
195+
(personalities, toolboxes, model configs, reusable taskflows), checks model
196+
names against the model config, validates prompt/`over` template syntax, and
197+
reports unknown fields (likely typos):
198+
199+
```bash
200+
# Validate a taskflow and its references
201+
python -m seclab_taskflow_agent --lint -t examples.taskflows.echo
202+
203+
# Treat unknown fields as errors (not just warnings)
204+
python -m seclab_taskflow_agent --lint --strict -t examples.taskflows.echo
205+
```
206+
207+
`--lint` exits non-zero if any errors are found, so it can gate CI. The JSON
208+
Schema for each grammar document type can be printed with `--schema` for editor
209+
integration and external validation:
210+
211+
```bash
212+
python -m seclab_taskflow_agent --schema
213+
```
214+
177215
### MCP Environment Denylist
178216

179217
By default, MCP server subprocesses inherit the parent environment. To prevent
@@ -199,6 +237,28 @@ The framework includes a [CodeQL](https://codeql.github.com/) MCP server that ca
199237

200238
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.
201239

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+
202262
## Requirements
203263

204264
Python >= 3.10 or Docker

doc/GRAMMAR.md

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,86 @@ Parameters to the model can also be specified in the task using the `model_setti
103103

104104
If `model_settings` is absent, then the model parameters will fall back to either the default or the ones supplied in a `model_config`. However, any parameters supplied in the task will override those that are set in the `model_config`.
105105

106+
### Multiple Models (multi-model tasks)
107+
108+
A task can be run against several models at once using the `models` field. Each
109+
model runs the task in parallel and its output is streamed as its own labelled
110+
block, which makes it easy to compare how different models respond to the same
111+
prompt (for example when evaluating an audit prompt across model families).
112+
113+
The simplest form is a list of logical model names (resolved through the
114+
`model_config` exactly like the singular `model` field):
115+
116+
```yaml
117+
- task:
118+
models: [gpt_default, claude_native, gpt_responses]
119+
agents:
120+
- seclab_taskflow_agent.personalities.c_auditer
121+
user_prompt: |
122+
Audit this function for memory safety issues.
123+
```
124+
125+
Each entry may also be a map with its own `model_settings`, so different models
126+
can use different parameters in the same task:
127+
128+
```yaml
129+
- task:
130+
models:
131+
- model: gpt_default
132+
model_settings:
133+
temperature: 0.2
134+
- model: claude_native
135+
model_settings:
136+
reasoning:
137+
effort: high
138+
agents:
139+
- seclab_taskflow_agent.personalities.c_auditer
140+
user_prompt: |
141+
Audit this function for memory safety issues.
142+
```
143+
144+
Notes and semantics:
145+
146+
- `model` (singular) and `models` (plural) are mutually exclusive. `model: x`
147+
is exactly equivalent to `models: [x]`; existing single-model taskflows are
148+
unaffected.
149+
- Per-entry `model_settings` support the same engine keys as `model_config`
150+
(`api_type`, `endpoint`, `token`, `backend`), so different models may run on
151+
different backends within one task.
152+
- `completion` controls when the task counts as complete across its fan-out
153+
branches: `all` (default, every branch must succeed) or `any` (one branch
154+
succeeding is enough). This is what `must_complete` checks against.
155+
- `model_concurrency` caps how many branches run at once for a multi-model
156+
task (default `0` runs all models in parallel):
157+
158+
```yaml
159+
- task:
160+
models: [m1, m2, m3, m4]
161+
model_concurrency: 2
162+
completion: any
163+
agents:
164+
- seclab_taskflow_agent.personalities.assistant
165+
user_prompt: |
166+
...
167+
```
168+
169+
- Multi-model output is buffered per model and flushed as a labelled block when
170+
each model finishes, so streams from different models do not interleave.
171+
- `models` can be combined with `repeat_prompt`: the task runs the cross
172+
product of items x models concurrently, bounded by `model_concurrency`
173+
(default: all branches at once). Each branch is streamed as its own block
174+
labelled `<model> [item <n>]`.
175+
- Multi-model tool results are not threaded into the implicit last-tool-result
176+
channel that the next task's `repeat_prompt` reads (that stays deterministic).
177+
To consume multi-model results downstream, give the task an `id`: each
178+
branch's final result is aggregated into `outputs.<id>` as a list of records
179+
`{"model": ..., "item": ..., "result": ...}` (see "Typed named outputs").
180+
- The inline `outputs` schema is applied per branch on multi-model tasks: each
181+
branch's result is validated against the schema and stored as the
182+
`result` of its fan-in record. A branch whose result violates the schema is
183+
treated as a failed branch, which the `completion` policy (`all`/`any`) then
184+
reduces like any other branch failure (see "Typed named outputs").
185+
106186
### Completion Requirement
107187

108188
Tasks can be marked as requiring completion, if a required task fails, the taskflow will abort. This defaults to false.
@@ -118,6 +198,69 @@ Example:
118198
...
119199
```
120200

201+
### Conditional execution (`if`)
202+
203+
A task can be gated with a GitHub-Actions-style `if` condition: a Jinja
204+
expression evaluated against the template context (`globals`, `inputs`, and
205+
prior tasks' `outputs`). When it evaluates falsy the task is skipped (recorded
206+
as skipped and not run); otherwise it runs normally.
207+
208+
```yaml
209+
- task:
210+
id: audit
211+
agents: [seclab_taskflow_agent.personalities.c_auditer]
212+
user_prompt: |
213+
Audit this code and report findings as JSON: {"findings": [...]}
214+
outputs:
215+
type: object
216+
properties:
217+
findings:
218+
type: array
219+
required: [findings]
220+
- task:
221+
# only remediate when the audit actually found something
222+
if: "outputs.audit.findings | length > 0"
223+
agents: [seclab_taskflow_agent.personalities.assistant]
224+
user_prompt: |
225+
Propose fixes for: {{ outputs.audit.findings }}
226+
```
227+
228+
Notes:
229+
230+
- The expression may be written bare (`globals.mode == 'deep'`) or wrapped in
231+
`{{ ... }}`. Standard truthiness applies (empty list/string/`0`/`false` are
232+
falsy).
233+
- Referencing a name that does not exist (for example an output from a task that
234+
has not run) is treated as falsy, so the task is skipped rather than failing,
235+
matching GitHub Actions semantics. To be explicit you can still guard with
236+
`is defined`, e.g. `if: "outputs.audit is defined and outputs.audit.findings"`.
237+
- `if` composes with everything else: a skipped task does not run its agents,
238+
fan out over `models`, or capture outputs.
239+
240+
### Conditionals and loops inside a prompt
241+
242+
Because prompts are rendered with Jinja2, you can use `{% if %}` / `{% for %}`
243+
directly inside a `user_prompt`:
244+
245+
```yaml
246+
- task:
247+
agents: [seclab_taskflow_agent.personalities.c_auditer]
248+
user_prompt: |
249+
{% if globals.mode == 'deep' %}
250+
Perform a DEEP audit of {{ globals.target }}.
251+
{% else %}
252+
Do a quick scan of {{ globals.target }}.
253+
{% endif %}
254+
{% for area in globals.focus %}
255+
- pay attention to {{ area }}
256+
{% endfor %}
257+
```
258+
259+
Unlike the task-level `if` condition (which treats undefined names as falsy and
260+
skips the task), undefined variables inside a prompt raise, to catch typos. Use
261+
`is defined` or the `default` filter for optional data:
262+
`{{ globals.note | default('') }}`.
263+
121264
### Running templated tasks in a loop
122265

123266
Often we may want to iterate through the same tasks with different inputs. For example, we may want to fetch all the functions from a code base and then analyze each of the functions. This can be done using two consecutive tasks and with the help of the `repeat_prompt` field.
@@ -259,6 +402,129 @@ Example:
259402
- seclab_taskflow_agent.toolboxes.codeql
260403
```
261404
405+
### Typed named outputs
406+
407+
By default, data flows between tasks implicitly: `repeat_prompt` consumes the
408+
*last tool result* of the previous task. That is positional (whichever tool
409+
fired last) and untyped. A task can instead publish a **named, typed output**
410+
that later tasks consume by name.
411+
412+
Three fields drive this:
413+
414+
- `id` names a task, exposing its output to later tasks as `outputs.<id>`. The
415+
shape depends on whether the task fans out:
416+
- A plain task (single model, no `repeat_prompt`) publishes its single
417+
produced value (its final tool result).
418+
- A task that fans out (`repeat_prompt`, multiple `models`, or their cross
419+
product) publishes a per-branch fan-in list of
420+
`{"model": <label>, "item": <index>, "result": <value>}` records, one per
421+
branch. This is uniform across the item axis and the model axis, so a
422+
single-model `repeat_prompt` and a multi-model task capture the same way.
423+
- `outputs` declares an inline JSON Schema (Draft 2020-12). When present, the
424+
task's value is validated against it before being stored. Validation is strict
425+
and does not coerce, so a value whose types do not match the contract is a
426+
failure. On a fan-out task the schema is applied to each branch's `result`
427+
(a violation is a failed branch under the `completion` policy); on a plain
428+
task it is applied to the single value (a violation is a hard failure). A
429+
malformed schema is rejected when the taskflow is loaded, before any model
430+
calls are made.
431+
- `over` is an explicit iterable selector for `repeat_prompt`: a Jinja
432+
expression evaluated against the template context (so it yields a real list,
433+
not a re-parsed string).
434+
435+
Example: one task produces a typed list of functions, the next analyses each.
436+
437+
```yaml
438+
- task:
439+
id: list_functions
440+
agents: [seclab_taskflow_agent.personalities.assistant]
441+
user_prompt: |
442+
List all functions as JSON: {"functions": [{"name": ..., "body": ...}]}
443+
outputs:
444+
type: object
445+
properties:
446+
functions:
447+
type: array
448+
items:
449+
type: object
450+
properties:
451+
name: {type: string}
452+
body: {type: string}
453+
required: [name, body]
454+
required: [functions]
455+
- task:
456+
repeat_prompt: true
457+
over: "outputs.list_functions.functions"
458+
agents: [seclab_taskflow_agent.personalities.c_auditer]
459+
user_prompt: |
460+
Analyze function {{ result.name }}:
461+
{{ result.body }}
462+
```
463+
464+
The `outputs` schema is a standard JSON Schema (Draft 2020-12), authored inline
465+
in YAML, so the full vocabulary is available:
466+
467+
- Types via `type` (`object`, `array`, `string`, `integer`, `number`,
468+
`boolean`, `null`), with `properties`/`required` for objects and `items` for
469+
arrays.
470+
- `enum`/`const` for fixed value sets, and constraints such as `minimum`,
471+
`maximum`, `minLength`, and `pattern`.
472+
- Objects with dynamic keys via `additionalProperties`, and strictness via
473+
`additionalProperties: false`.
474+
- Unions (`anyOf`/`oneOf`), and `$ref`/`$defs` to reuse a shape across fields.
475+
476+
Because validation is strict (no coercion), have the task emit JSON whose types
477+
already match, e.g. the integer `7` rather than the string `"7"`.
478+
479+
Notes:
480+
481+
- `outputs.<id>` is a template namespace alongside `globals`, `inputs`, and
482+
the per-iteration `result`. Keys named after dict methods (`items`, `keys`,
483+
`values`, ...) resolve to your data, not the method.
484+
- Without `id`/`outputs`/`over`, the implicit last-tool-result `repeat_prompt`
485+
behaviour is unchanged.
486+
- Implicit carry-over (the next task's `repeat_prompt` reading the previous
487+
task's last tool result) is fed by single-model tasks only. A multi-model
488+
task does not feed it: there is no single "last" result across models, so a
489+
downstream task must consume its output by name via `id`/`over`.
490+
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+
262528
### Toolboxes / MCP Servers
263529

264530
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:

0 commit comments

Comments
 (0)