feat(delegation): optional structured-output schema on delegate_task

Per-task `output_schema` (JSON Schema object) on task items plus the
top-level single-goal form — a one-time static addition to the tool
schema (never varies per call).

- Child side: the schema is appended to the child's context as an
  explicit OUTPUT CONTRACT block before spawn.
- Completion side: the parent validates the child's final answer with
  jsonschema; on failure it sends exactly ONE bounded retry turn
  carrying the validation errors verbatim (no schema re-paste).
- Result entries gain schema_valid (+ schema_retries, and schema_errors
  on final failure) ONLY when a schema was requested; schema-less calls
  keep a byte-identical result shape.
- Malformed schemas are rejected loudly at dispatch (coerce_output_schema
  meta-validates via jsonschema's validator_for/check_schema).
- New helpers in tools/delegation_output_schema.py: coerce, contract
  block, fence/prose-tolerant extraction+validation, retry message.

Pattern from: github/copilot-cli ctx.agent(prompt,{schema}) — PATTERN
ONLY, zero code/prompt text copied (proprietary); proven consumer:
delegate-task-output-patterns skill.

Tests: tests/tools/test_delegate_output_schema.py (24 tests — valid
first try, invalid->retry->valid, invalid twice -> schema_valid false +
errors surfaced, retry-exception degrade, no-schema legacy shape pin,
dispatch rejection, contract plumbing). Delegation suite: 221/221 green.
This commit is contained in:
Teknium 2026-08-07 07:53:01 -07:00
parent e166159f26
commit d6ee58b583
3 changed files with 638 additions and 2 deletions

View File

@ -0,0 +1,357 @@
"""T1-24: structured-output schema on delegate_task.
Per-task ``output_schema`` (JSON Schema object): the child receives the
schema as an explicit output contract, the parent validates the child's
final answer with jsonschema, and on failure sends exactly ONE bounded
retry turn carrying the validation errors. Result entries gain
``schema_valid`` / ``schema_errors`` / ``schema_retries`` ONLY when a
schema was requested schema-less calls keep a byte-identical result
shape (wire-shape pinning).
Pattern from: github/copilot-cli ctx.agent(prompt, {schema}) PATTERN
ONLY, zero code/prompt text copied (proprietary).
"""
import json
import threading
from unittest.mock import MagicMock, patch
from tools.delegate_tool import (
DELEGATE_TASK_SCHEMA,
_run_single_child,
delegate_task,
)
from tools.delegation_output_schema import (
append_output_contract,
build_retry_message,
coerce_output_schema,
validate_output,
)
ADDRESS_SCHEMA = {
"type": "object",
"properties": {
"city": {"type": "string"},
"zip": {"type": "string"},
},
"required": ["city"],
}
# ---------------------------------------------------------------------------
# Helper-module unit tests
# ---------------------------------------------------------------------------
class TestValidateOutput:
def test_valid_json_matching_schema(self):
ok, errors = validate_output('{"city": "Berlin"}', ADDRESS_SCHEMA)
assert ok is True
assert errors == []
def test_json_violating_schema_reports_errors(self):
ok, errors = validate_output('{"zip": "10115"}', ADDRESS_SCHEMA)
assert ok is False
assert errors
assert any("city" in e for e in errors)
def test_non_json_text_reports_parse_error(self):
ok, errors = validate_output("I could not produce JSON, sorry.", ADDRESS_SCHEMA)
assert ok is False
assert errors
def test_code_fenced_json_is_accepted(self):
text = '```json\n{"city": "Oslo"}\n```'
ok, errors = validate_output(text, ADDRESS_SCHEMA)
assert ok is True
assert errors == []
def test_json_embedded_in_prose_is_extracted(self):
text = 'Here is the result:\n{"city": "Lima"}\nHope that helps!'
ok, _ = validate_output(text, ADDRESS_SCHEMA)
assert ok is True
def test_empty_text_is_invalid(self):
ok, errors = validate_output("", ADDRESS_SCHEMA)
assert ok is False
assert errors
class TestCoerceOutputSchema:
def test_valid_schema_passes(self):
schema, err = coerce_output_schema(ADDRESS_SCHEMA)
assert schema == ADDRESS_SCHEMA
assert err is None
def test_none_passes_through(self):
schema, err = coerce_output_schema(None)
assert schema is None
assert err is None
def test_non_dict_is_rejected(self):
schema, err = coerce_output_schema("not a schema")
assert schema is None
assert err
def test_invalid_json_schema_is_rejected(self):
schema, err = coerce_output_schema({"type": 42})
assert schema is None
assert err
class TestPromptPlumbing:
def test_contract_block_carries_schema(self):
out = append_output_contract("base context", ADDRESS_SCHEMA)
assert "base context" in out
assert "OUTPUT CONTRACT" in out
assert '"city"' in out
def test_contract_block_without_prior_context(self):
out = append_output_contract(None, ADDRESS_SCHEMA)
assert "OUTPUT CONTRACT" in out
def test_retry_message_carries_verbatim_errors(self):
msg = build_retry_message(["'city' is a required property"])
assert "'city' is a required property" in msg
assert "JSON" in msg
# ---------------------------------------------------------------------------
# Tool-schema surface (one-time static field)
# ---------------------------------------------------------------------------
class TestToolSchemaSurface:
def test_output_schema_on_task_items(self):
item_props = DELEGATE_TASK_SCHEMA["parameters"]["properties"]["tasks"][
"items"
]["properties"]
assert "output_schema" in item_props
assert item_props["output_schema"]["type"] == "object"
# never required
assert "output_schema" not in DELEGATE_TASK_SCHEMA["parameters"][
"properties"
]["tasks"]["items"]["required"]
def test_output_schema_on_top_level_goal_form(self):
props = DELEGATE_TASK_SCHEMA["parameters"]["properties"]
assert "output_schema" in props
assert props["output_schema"]["type"] == "object"
# ---------------------------------------------------------------------------
# _run_single_child validation + bounded retry
# ---------------------------------------------------------------------------
class _StubChild:
"""Minimal child agent double (mirrors test_delegate_kanban_isolation)."""
tool_progress_callback = None
_delegate_saved_tool_names: list = []
_credential_pool = None
_subagent_id = None # skip registry
_delegate_depth = 1
_parent_subagent_id = None
_delegate_output_schema: dict | None = None
model = "test-model"
session_prompt_tokens = 0
session_completion_tokens = 0
session_estimated_cost_usd = 0.0
session_reasoning_tokens = 0
def __init__(self, responses):
self.responses = list(responses)
self.calls: list = []
def get_activity_summary(self):
return {"api_call_count": 1, "max_iterations": 5, "current_tool": None}
def run_conversation(self, user_message, task_id=None, **_kwargs):
self.calls.append(user_message)
text = self.responses.pop(0)
return {
"final_response": text,
"completed": True,
"api_calls": 1,
"messages": [],
}
def close(self):
return None
class _StubParent:
_current_task_id = None
_delegate_depth = 0
def _touch_activity(self, _desc):
return None
def _run(child):
return _run_single_child(0, "produce the address", child, _StubParent())
class TestRunSingleChildSchemaValidation:
def test_valid_first_try_no_retry(self):
child = _StubChild(['{"city": "Berlin"}'])
child._delegate_output_schema = ADDRESS_SCHEMA
entry = _run(child)
assert entry["status"] == "completed"
assert entry["schema_valid"] is True
assert "schema_errors" not in entry
assert len(child.calls) == 1
def test_invalid_then_retry_then_valid(self):
child = _StubChild(["not json at all", '{"city": "Oslo"}'])
child._delegate_output_schema = ADDRESS_SCHEMA
entry = _run(child)
assert entry["schema_valid"] is True
assert entry["schema_retries"] == 1
# retry turn carried the validation errors
assert len(child.calls) == 2
assert "rejected" in child.calls[1] or "JSON" in child.calls[1]
# final summary is the retried (valid) answer
assert json.loads(entry["summary"])["city"] == "Oslo"
def test_invalid_twice_surfaces_errors_and_stops(self):
child = _StubChild(["nope", "still nope"])
child._delegate_output_schema = ADDRESS_SCHEMA
entry = _run(child)
assert entry["schema_valid"] is False
assert entry["schema_errors"]
assert entry["schema_retries"] == 1
# exactly ONE retry — bounded
assert len(child.calls) == 2
def test_retry_exception_degrades_to_invalid(self):
child = _StubChild(["nope"])
child._delegate_output_schema = ADDRESS_SCHEMA
original = child.run_conversation
def flaky(user_message, task_id=None, **kw):
if child.calls:
raise RuntimeError("child died on retry")
return original(user_message, task_id=task_id, **kw)
child.run_conversation = flaky
entry = _run(child)
assert entry["schema_valid"] is False
assert entry["schema_errors"]
def test_no_schema_keeps_legacy_result_shape(self):
"""Schema-less calls must not gain new keys (wire-shape pinning)."""
child = _StubChild(['{"city": "Berlin"}'])
entry = _run(child)
assert "schema_valid" not in entry
assert "schema_errors" not in entry
assert "schema_retries" not in entry
assert len(child.calls) == 1
def test_failed_child_skips_validation(self):
"""A child with no output never gets a schema retry turn."""
child = _StubChild([""])
child._delegate_output_schema = ADDRESS_SCHEMA
entry = _run(child)
assert entry["status"] == "failed"
assert len(child.calls) == 1
assert entry.get("schema_valid") is False
# ---------------------------------------------------------------------------
# delegate_task dispatch-time schema handling
# ---------------------------------------------------------------------------
def _make_mock_parent():
parent = MagicMock()
parent._delegate_depth = 0
parent._active_children = []
parent._active_children_lock = threading.Lock()
return parent
class TestDelegateTaskDispatch:
def test_non_dict_output_schema_rejected(self):
with (
patch("tools.delegate_tool._load_config", return_value={}),
patch(
"tools.delegate_tool._resolve_delegation_credentials",
return_value={
"provider": None,
"model": None,
"base_url": None,
"api_key": None,
"api_mode": None,
},
),
):
out = delegate_task(
tasks=[{"goal": "x", "output_schema": "not-a-dict"}],
parent_agent=_make_mock_parent(),
)
payload = json.loads(out)
assert payload.get("error")
assert "output_schema" in payload["error"]
def test_invalid_json_schema_rejected_at_dispatch(self):
with (
patch("tools.delegate_tool._load_config", return_value={}),
patch(
"tools.delegate_tool._resolve_delegation_credentials",
return_value={
"provider": None,
"model": None,
"base_url": None,
"api_key": None,
"api_mode": None,
},
),
):
out = delegate_task(
tasks=[{"goal": "x", "output_schema": {"type": 42}}],
parent_agent=_make_mock_parent(),
)
payload = json.loads(out)
assert payload.get("error")
assert "output_schema" in payload["error"]
def test_child_receives_contract_and_schema_attr(self):
"""The built child carries the schema attr and its context gains
the output-contract block."""
captured = {}
def fake_build(**kwargs):
captured.update(kwargs)
child = _StubChild(['{"city": "Rio"}'])
return child
with (
patch("tools.delegate_tool._load_config", return_value={}),
patch(
"tools.delegate_tool._resolve_delegation_credentials",
return_value={
"provider": None,
"model": None,
"base_url": None,
"api_key": None,
"api_mode": None,
},
),
patch(
"tools.delegate_tool._build_child_preserving_parent_tools",
side_effect=fake_build,
),
):
out = delegate_task(
goal="produce the address",
context="base context",
output_schema=ADDRESS_SCHEMA,
parent_agent=_make_mock_parent(),
)
payload = json.loads(out)
assert "OUTPUT CONTRACT" in (captured.get("context") or "")
results = payload.get("results") or []
assert results and results[0].get("schema_valid") is True

View File

@ -2458,6 +2458,67 @@ def _run_single_child(
# is stuck on blocking I/O, wait=True would hang forever.
_timeout_executor.shutdown(wait=False)
# T1-24: structured-output contract validation + ONE bounded retry.
# Runs only when a schema was attached at dispatch; schema-less
# delegations take none of these branches and their result entry
# stays byte-identical (wire-shape pinning).
# Pattern from: github/copilot-cli ctx.agent(prompt, {schema}) —
# PATTERN ONLY, no code copied.
_output_schema = getattr(child, "_delegate_output_schema", None)
_schema_valid: Optional[bool] = None
_schema_errors: List[str] = []
_schema_retries = 0
if isinstance(_output_schema, dict):
from tools.delegation_output_schema import (
build_retry_message,
validate_output,
)
_first_text = result.get("final_response") or ""
_schema_valid, _schema_errors = validate_output(
_first_text, _output_schema
)
if (
not _schema_valid
and _first_text.strip()
and not result.get("interrupted", False)
):
# Exactly one retry turn, carrying the validation errors
# verbatim (no schema re-paste — the child already holds
# the contract in its context).
_schema_retries = 1
_retry_result = None
try:
_retry_result = child.run_conversation(
user_message=build_retry_message(_schema_errors),
task_id=child_task_id,
stream_callback=_relay_child_text,
)
except Exception as _retry_exc:
logger.warning(
"Subagent %d schema-retry turn failed: %s",
task_index,
_retry_exc,
)
if isinstance(_retry_result, dict):
_retry_text = _retry_result.get("final_response") or ""
if _retry_text.strip():
result["final_response"] = _retry_text
try:
result["api_calls"] = int(
result.get("api_calls", 0) or 0
) + int(_retry_result.get("api_calls", 0) or 0)
except (TypeError, ValueError):
pass
_retry_messages = _retry_result.get("messages")
if isinstance(_retry_messages, list) and isinstance(
result.get("messages"), list
):
result["messages"] = result["messages"] + _retry_messages
_schema_valid, _schema_errors = validate_output(
_retry_text, _output_schema
)
# Linearization boundary for registry steering. From this point on the
# child cannot consume another steer. Closing under the registry lock
# either rejects a concurrent caller or drains every previously accepted
@ -2604,6 +2665,15 @@ def _run_single_child(
if status == "failed":
entry["error"] = result.get("error", "Subagent did not produce a response.")
# T1-24: schema-validation outcome — emitted ONLY when a schema was
# requested, so legacy (schema-less) payloads keep their exact shape.
if isinstance(_output_schema, dict):
entry["schema_valid"] = bool(_schema_valid)
if _schema_retries:
entry["schema_retries"] = _schema_retries
if not _schema_valid and _schema_errors:
entry["schema_errors"] = _schema_errors
# A steer that queued after the child's final assistant turn had no
# tool batch left to drain into. The finalizer hands the undelivered
# text back (turn_finalizer.py "pending_steer"); retain it here so the
@ -3053,6 +3123,7 @@ def delegate_task(
max_iterations: Optional[int] = None,
role: Optional[str] = None,
background: Optional[bool] = None,
output_schema: Optional[Dict[str, Any]] = None,
parent_agent=None,
) -> str:
"""
@ -3151,7 +3222,10 @@ def delegate_task(
)
task_list = tasks
elif goal and isinstance(goal, str) and goal.strip():
task_list = [{"goal": goal, "context": context, "role": top_role}]
single_task: Dict[str, Any] = {"goal": goal, "context": context, "role": top_role}
if output_schema is not None:
single_task["output_schema"] = output_schema
task_list = [single_task]
else:
return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).")
@ -3176,6 +3250,23 @@ def delegate_task(
if batch_error:
return tool_error(batch_error)
# T1-24: coerce/validate optional per-task output_schema up front so a
# malformed schema fails the whole call loudly instead of spawning
# children that can never satisfy their contract. Runs AFTER the
# existing goal checks; schema-less tasks resolve to None and take no
# new code paths downstream.
from tools.delegation_output_schema import coerce_output_schema
task_schemas: List[Optional[Dict[str, Any]]] = []
for i, task in enumerate(task_list):
raw_schema = task.get("output_schema")
if raw_schema is None and len(task_list) == 1 and output_schema is not None:
raw_schema = output_schema
coerced_schema, schema_err = coerce_output_schema(raw_schema)
if schema_err:
return tool_error(f"Task {i} output_schema invalid: {schema_err}")
task_schemas.append(coerced_schema)
overall_start = time.monotonic()
results = []
@ -3229,10 +3320,18 @@ def delegate_task(
# Per-task role beats top-level; normalise again so unknown
# per-task values warn and degrade to leaf uniformly.
effective_role = _normalize_role(t.get("role") or top_role)
# T1-24: schema'd tasks get the contract appended to their context
# so the child knows the expected output shape before it starts.
_task_schema = task_schemas[i] if i < len(task_schemas) else None
_child_context = t.get("context")
if _task_schema is not None:
from tools.delegation_output_schema import append_output_contract
_child_context = append_output_contract(_child_context, _task_schema)
child = _build_child_preserving_parent_tools(
task_index=i,
goal=t["goal"],
context=t.get("context"),
context=_child_context,
# Subagents always inherit the parent's toolsets; the model
# cannot choose or narrow them (no model-facing toolsets arg).
toolsets=None,
@ -3250,6 +3349,13 @@ def delegate_task(
override_acp_args=creds.get("args"),
role=effective_role,
)
# Attach the validated schema for the completion-side validation
# hook in _run_single_child. Absent (None) on schema-less tasks.
if _task_schema is not None:
try:
child._delegate_output_schema = _task_schema
except Exception:
logger.debug("Could not attach output schema to child %d", i)
# Tee the child's progress events into its live transcript log.
# wrap_progress_callback preserves the inner callback contract
# (including the _flush attribute) and never lets writer failures
@ -4121,6 +4227,19 @@ DELEGATE_TASK_SCHEMA = {
"enum": ["leaf", "orchestrator"],
"description": "Per-task role override. See top-level 'role' for semantics.",
},
"output_schema": {
"type": "object",
"description": (
"Optional JSON Schema the subagent's final "
"answer must validate against. The child is "
"told the contract up front; the parent "
"validates the final answer and allows one "
"bounded correction retry. The result entry "
"gains schema_valid (and schema_errors on "
"final failure). Keep schemas forgiving: "
"require only fields you will actually read."
),
},
},
"required": ["goal"],
},
@ -4134,6 +4253,14 @@ DELEGATE_TASK_SCHEMA = {
"enum": ["leaf", "orchestrator"],
"description": "(rebuilt at get_definitions() time)",
},
"output_schema": {
"type": "object",
"description": (
"Optional JSON Schema for the single-goal form — the "
"subagent's final answer must validate against it "
"(same semantics as tasks[].output_schema)."
),
},
"background": {
"type": "boolean",
"description": (
@ -4206,6 +4333,7 @@ registry.register(
max_iterations=args.get("max_iterations"),
role=args.get("role"),
background=_model_background_value(args, kw.get("parent_agent")),
output_schema=args.get("output_schema"),
parent_agent=kw.get("parent_agent"),
),
check_fn=check_delegate_requirements,

View File

@ -0,0 +1,151 @@
"""Structured-output schema helpers for delegate_task (T1-24).
Optional per-task ``output_schema`` (a JSON Schema object): the child is
told about the contract via an OUTPUT CONTRACT block appended to its
context, the parent validates the child's final answer with jsonschema,
and on failure sends exactly ONE bounded retry turn carrying the
validation errors verbatim (per llm-structured-output-schema-design:
max 1 retry, exact errors, no schema re-paste).
Pattern from: github/copilot-cli ctx.agent(prompt, {schema}) PATTERN
ONLY, zero code/prompt text copied (proprietary).
"""
from __future__ import annotations
import json
import logging
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# Exactly one retry turn — bounded by design. More retries make frontier
# models drop fields that were right the first time.
MAX_SCHEMA_RETRIES = 1
_CONTRACT_HEADER = "OUTPUT CONTRACT (machine-validated)"
def coerce_output_schema(raw: Any) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
"""Validate a model/caller-supplied output_schema value.
Returns ``(schema, None)`` when usable, ``(None, error)`` when not.
``None`` input passes through as ``(None, None)`` (no schema requested).
"""
if raw is None:
return None, None
if isinstance(raw, str):
# Models sometimes double-encode the schema as a JSON string.
try:
parsed = json.loads(raw)
except (ValueError, TypeError):
return None, "output_schema must be a JSON Schema object, got a non-JSON string."
if not isinstance(parsed, dict):
return None, "output_schema must be a JSON Schema object."
raw = parsed
if not isinstance(raw, dict):
return None, (
f"output_schema must be a JSON Schema object, got {type(raw).__name__}."
)
try:
from jsonschema.validators import validator_for # type: ignore[import-untyped]
validator_for(raw).check_schema(raw)
except ImportError:
# jsonschema is a hard dependency in practice; degrade to accepting
# the dict as-is so delegation still works without it.
logger.debug("jsonschema unavailable; skipping output_schema meta-validation")
except Exception as exc:
return None, f"output_schema is not a valid JSON Schema: {exc}"
return raw, None
def append_output_contract(context: Optional[str], schema: Dict[str, Any]) -> str:
"""Append the explicit output contract block to a child's context."""
try:
schema_text = json.dumps(schema, indent=2, ensure_ascii=False)
except (TypeError, ValueError):
schema_text = str(schema)
block = (
f"{_CONTRACT_HEADER}:\n"
"Your FINAL response must be a single JSON object that validates "
"against this JSON Schema. No prose before or after the JSON; a "
"```json code fence is acceptable but not required.\n"
f"{schema_text}"
)
base = (context or "").rstrip()
return f"{base}\n\n{block}" if base else block
def extract_json_candidate(text: str) -> str:
"""Best-effort extraction of a JSON payload from model output.
Strips markdown code fences and leading/trailing prose around the
outermost ``{...}`` / ``[...]`` span. Returns the (possibly unchanged)
candidate string; parsing errors are reported by validate_output.
"""
raw = (text or "").strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[-1]
if raw.rstrip().endswith("```"):
raw = raw.rstrip()[: -3]
raw = raw.strip()
if raw.lower().startswith("json\n"):
raw = raw.split("\n", 1)[1]
for opener, closer in (("{", "}"), ("[", "]")):
if raw.startswith(opener):
return raw
start = raw.find(opener)
end = raw.rfind(closer)
if start >= 0 and end > start:
return raw[start : end + 1]
return raw
def validate_output(
text: str, schema: Dict[str, Any]
) -> Tuple[bool, List[str]]:
"""Validate a child's final answer against ``schema``.
Returns ``(True, [])`` on success or ``(False, errors)`` where errors
are human-readable strings suitable for the retry turn.
"""
candidate = extract_json_candidate(text or "")
if not candidate.strip():
return False, ["Response was empty — expected a JSON object matching the schema."]
try:
parsed = json.loads(candidate)
except (ValueError, TypeError) as exc:
return False, [f"Response is not valid JSON: {exc}"]
try:
from jsonschema.validators import validator_for # type: ignore[import-untyped]
except ImportError:
logger.debug("jsonschema unavailable; accepting parsed JSON without validation")
return True, []
validator = validator_for(schema)(schema)
errors = sorted(validator.iter_errors(parsed), key=lambda e: list(e.absolute_path))
if not errors:
return True, []
rendered: List[str] = []
for err in errors[:10]: # bound error volume for the retry prompt
path = "$" + "".join(
f"[{p}]" if isinstance(p, int) else f".{p}" for p in err.absolute_path
)
rendered.append(f"{path}: {err.message}")
return False, rendered
def build_retry_message(errors: List[str]) -> str:
"""Build the single bounded retry turn sent to the child.
Carries the validation errors verbatim; deliberately does NOT
re-paste the schema (the child already has it in its context).
"""
error_block = "\n".join(f"- {e}" for e in errors)
return (
"Your previous final response was rejected by the output contract "
"validator. Validation errors:\n"
f"{error_block}\n\n"
"Reply with ONLY the corrected JSON object matching the OUTPUT "
"CONTRACT schema from your task context. No prose, no explanations."
)