Skip to content

Commit 253299a

Browse files
ZhiXiao-Linclaude
andauthored
feat(orchestration): schema-forced step output (Phase 2) (#52)
A step can now require schema-validated structured output, mirroring Claude Code's agent(prompt, {schema}). - AgentStepSpec gains `output_schema: Option<Value>` (+ with_output_schema); StepOutcome gains `structured: Option<Value>`. Both optional + serde-default, so pre-Phase-2 payloads still load (Value isn't Eq → derives PartialEq only). - The local executor (TaskExecutor::execute_step) runs the step normally, then coerces its output to the schema via the existing structured-output machinery (generate_blocking, Tool mode — portable, with built-in repair). A coercion failure demotes the step to unsuccessful so callers never treat unvalidated text as the promised object. The executor owns schema fulfilment, so a remote host can satisfy it however it likes (the requirement travels in the spec). Tests: mock-based coercion (schema → validated object; no-schema → none), spec/outcome round-trip incl. the new fields + backward-compat, and a real-LLM \#[ignore] test (live model returns a schema-valid object via execute_step) passing against .a3s/config.acl. Task suite + orchestration tests green; fmt + clippy --lib --bins clean. Co-authored-by: Claude <claude@anthropic.com>
1 parent 3162b74 commit 253299a

3 files changed

Lines changed: 304 additions & 4 deletions

File tree

core/src/orchestration/executor.rs

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ use tokio::sync::broadcast;
1313
/// Serializable on purpose: a host (书安OS) may ship it to another node, and
1414
/// a future workflow checkpoint persists it. The orchestration layer assigns
1515
/// `task_id`; everything else mirrors a delegated task.
16-
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16+
// `serde_json::Value` (in `output_schema`) is not `Eq`, so this derives
17+
// `PartialEq` only.
18+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1719
pub struct AgentStepSpec {
1820
/// Stable id for this step. Flows into lifecycle events (and, later,
1921
/// workflow checkpoints) so a step can be correlated and resumed.
@@ -30,6 +32,12 @@ pub struct AgentStepSpec {
3032
/// Parent session id, for lifecycle-event correlation.
3133
#[serde(default, skip_serializing_if = "Option::is_none")]
3234
pub parent_session_id: Option<String>,
35+
/// When set, the step must return a value conforming to this JSON Schema.
36+
/// The executor fulfills it (the local default coerces the step's output
37+
/// with the structured-output machinery); the validated object lands in
38+
/// [`StepOutcome::structured`].
39+
#[serde(default, skip_serializing_if = "Option::is_none")]
40+
pub output_schema: Option<serde_json::Value>,
3341
}
3442

3543
impl AgentStepSpec {
@@ -47,6 +55,7 @@ impl AgentStepSpec {
4755
prompt: prompt.into(),
4856
max_steps: None,
4957
parent_session_id: None,
58+
output_schema: None,
5059
}
5160
}
5261

@@ -59,16 +68,29 @@ impl AgentStepSpec {
5968
self.parent_session_id = Some(parent_session_id.into());
6069
self
6170
}
71+
72+
/// Require this step to return a value conforming to `schema`.
73+
pub fn with_output_schema(mut self, schema: serde_json::Value) -> Self {
74+
self.output_schema = Some(schema);
75+
self
76+
}
6277
}
6378

6479
/// The result of running one [`AgentStepSpec`] to completion.
65-
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80+
///
81+
/// `structured` is `Some` only when the spec carried an `output_schema` and
82+
/// the executor produced a value validated against it. (`serde_json::Value`
83+
/// is not `Eq`, so this derives `PartialEq` only.)
84+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6685
pub struct StepOutcome {
6786
pub task_id: String,
6887
pub session_id: String,
6988
pub agent: String,
7089
pub output: String,
7190
pub success: bool,
91+
/// Schema-validated structured output, when the step requested one.
92+
#[serde(default, skip_serializing_if = "Option::is_none")]
93+
pub structured: Option<serde_json::Value>,
7294
}
7395

7496
impl StepOutcome {
@@ -88,6 +110,7 @@ impl StepOutcome {
88110
agent: agent.into(),
89111
output: message.into(),
90112
success: false,
113+
structured: None,
91114
}
92115
}
93116
}
@@ -212,6 +235,7 @@ mod tests {
212235
agent: spec.agent.clone(),
213236
output: format!("ran: {}", spec.prompt),
214237
success: spec.agent != "fail",
238+
structured: None,
215239
}
216240
}
217241
fn concurrency_hint(&self) -> usize {
@@ -289,4 +313,45 @@ mod tests {
289313
}
290314
assert_eq!(Bare.concurrency_hint(), DEFAULT_MAX_PARALLEL_TASKS);
291315
}
316+
317+
#[test]
318+
fn spec_and_outcome_round_trip_including_new_optional_fields() {
319+
let schema = serde_json::json!({
320+
"type": "object",
321+
"properties": { "v": { "type": "string" } },
322+
"required": ["v"]
323+
});
324+
let spec = AgentStepSpec::new("t1", "explore", "d", "p")
325+
.with_max_steps(3)
326+
.with_parent_session_id("parent")
327+
.with_output_schema(schema.clone());
328+
let back: AgentStepSpec =
329+
serde_json::from_str(&serde_json::to_string(&spec).unwrap()).unwrap();
330+
assert_eq!(back, spec);
331+
assert_eq!(back.output_schema, Some(schema));
332+
333+
let outcome = StepOutcome {
334+
task_id: "t1".into(),
335+
session_id: "task-run-t1".into(),
336+
agent: "explore".into(),
337+
output: "ok".into(),
338+
success: true,
339+
structured: Some(serde_json::json!({ "v": "x" })),
340+
};
341+
let back: StepOutcome =
342+
serde_json::from_str(&serde_json::to_string(&outcome).unwrap()).unwrap();
343+
assert_eq!(back, outcome);
344+
345+
// Backward-compat: a pre-Phase-2 payload lacking the new optional
346+
// fields still loads (they default to None).
347+
let old_spec: AgentStepSpec =
348+
serde_json::from_str(r#"{"task_id":"t","agent":"a","description":"d","prompt":"p"}"#)
349+
.unwrap();
350+
assert_eq!(old_spec.output_schema, None);
351+
let old_outcome: StepOutcome = serde_json::from_str(
352+
r#"{"task_id":"t","session_id":"s","agent":"a","output":"o","success":true}"#,
353+
)
354+
.unwrap();
355+
assert_eq!(old_outcome.structured, None);
356+
}
292357
}

core/src/tools/task.rs

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
//! ```
1616
1717
use crate::agent::{AgentConfig, AgentEvent, AgentLoop};
18+
use crate::llm::structured::{generate_blocking, StructuredMode, StructuredRequest};
1819
use crate::llm::LlmClient;
1920
use crate::mcp::manager::McpManager;
2021
use crate::orchestration::{AgentExecutor, AgentStepSpec, StepOutcome};
@@ -517,6 +518,7 @@ impl TaskExecutor {
517518
prompt: params.prompt,
518519
max_steps: params.max_steps,
519520
parent_session_id: parent.clone(),
521+
output_schema: None,
520522
})
521523
.collect();
522524

@@ -537,6 +539,7 @@ impl From<TaskResult> for StepOutcome {
537539
agent: r.agent,
538540
output: r.output,
539541
success: r.success,
542+
structured: None,
540543
}
541544
}
542545
}
@@ -565,14 +568,15 @@ impl AgentExecutor for TaskExecutor {
565568
) -> StepOutcome {
566569
let agent = spec.agent.clone();
567570
let task_id = spec.task_id.clone();
571+
let output_schema = spec.output_schema.clone();
568572
let params = TaskParams {
569573
agent: spec.agent,
570574
description: spec.description,
571575
prompt: spec.prompt,
572576
background: false,
573577
max_steps: spec.max_steps,
574578
};
575-
match self
579+
let mut outcome: StepOutcome = match self
576580
.execute_with_task_id(
577581
task_id.clone(),
578582
params,
@@ -583,15 +587,65 @@ impl AgentExecutor for TaskExecutor {
583587
.await
584588
{
585589
Ok(result) => result.into(),
586-
Err(e) => StepOutcome::failed(task_id, agent, format!("Task failed: {e}")),
590+
Err(e) => return StepOutcome::failed(task_id, agent, format!("Task failed: {e}")),
591+
};
592+
593+
// When the step requested structured output, coerce the (succeeded)
594+
// free-text result to the schema. A coercion failure demotes the step
595+
// to unsuccessful so callers never treat unvalidated text as the
596+
// promised object.
597+
if outcome.success {
598+
if let Some(schema) = output_schema {
599+
match self.coerce_to_schema(&outcome.output, schema).await {
600+
Ok(object) => outcome.structured = Some(object),
601+
Err(e) => {
602+
outcome.success = false;
603+
outcome.output =
604+
format!("{}\n\n[structured output failed: {e}]", outcome.output);
605+
}
606+
}
607+
}
587608
}
609+
outcome
588610
}
589611

590612
fn concurrency_hint(&self) -> usize {
591613
self.max_parallel_tasks
592614
}
593615
}
594616

617+
impl TaskExecutor {
618+
/// Coerce a step's free-text output into a JSON object validated against
619+
/// `schema`, reusing the structured-output machinery (Tool mode — the most
620+
/// portable across providers, with built-in repair). This is one extra LLM
621+
/// call beyond the step's own run.
622+
async fn coerce_to_schema(
623+
&self,
624+
output: &str,
625+
schema: serde_json::Value,
626+
) -> Result<serde_json::Value> {
627+
let req = StructuredRequest {
628+
prompt: format!(
629+
"Convert the following task result into a single JSON object that conforms to \
630+
the required schema. Use only information present in the result.\n\n\
631+
--- TASK RESULT ---\n{output}"
632+
),
633+
system: Some(
634+
"You output exactly one JSON object matching the provided schema.".to_string(),
635+
),
636+
schema,
637+
schema_name: "step_output".to_string(),
638+
schema_description: None,
639+
// Tool mode works on every provider that supports tool use and
640+
// does not depend on response_format wiring.
641+
mode: StructuredMode::Tool,
642+
max_repair_attempts: 2,
643+
};
644+
let result = generate_blocking(&*self.llm_client, &req).await?;
645+
Ok(result.object)
646+
}
647+
}
648+
595649
/// Get the JSON schema for TaskParams
596650
pub fn task_params_schema() -> serde_json::Value {
597651
serde_json::json!({
@@ -1570,6 +1624,94 @@ mod tests {
15701624
.unwrap_or_default()
15711625
}
15721626

1627+
/// Client for the schema-coercion tests. The agent's own turn returns
1628+
/// plain text (which ends the loop); the structured-output coercion call
1629+
/// — recognizable by the injected `step_output` tool — returns a tool call
1630+
/// carrying the object.
1631+
struct SchemaCoercionClient;
1632+
1633+
#[async_trait::async_trait]
1634+
impl LlmClient for SchemaCoercionClient {
1635+
async fn complete(
1636+
&self,
1637+
messages: &[Message],
1638+
system: Option<&str>,
1639+
tools: &[ToolDefinition],
1640+
) -> Result<LlmResponse> {
1641+
if system == Some(crate::prompts::PRE_ANALYSIS_SYSTEM) {
1642+
return Ok(pre_analysis_response(messages));
1643+
}
1644+
// The structured-output coercion injects a synthetic tool named
1645+
// `emit_<schema_name>` (here `emit_step_output`).
1646+
if tools.iter().any(|t| t.name == "emit_step_output") {
1647+
return Ok(MockLlmClient::tool_call_response(
1648+
"coerce-1",
1649+
"emit_step_output",
1650+
serde_json::json!({ "verdict": "ok" }),
1651+
));
1652+
}
1653+
Ok(text_response("The verdict is ok."))
1654+
}
1655+
1656+
async fn complete_streaming(
1657+
&self,
1658+
_messages: &[Message],
1659+
_system: Option<&str>,
1660+
_tools: &[ToolDefinition],
1661+
_cancel_token: tokio_util::sync::CancellationToken,
1662+
) -> Result<mpsc::Receiver<StreamEvent>> {
1663+
anyhow::bail!("streaming is not used by schema coercion tests")
1664+
}
1665+
}
1666+
1667+
fn verdict_schema() -> serde_json::Value {
1668+
serde_json::json!({
1669+
"type": "object",
1670+
"properties": { "verdict": { "type": "string" } },
1671+
"required": ["verdict"]
1672+
})
1673+
}
1674+
1675+
#[tokio::test]
1676+
async fn execute_step_with_schema_coerces_structured_output() {
1677+
let workspace = tempfile::tempdir().unwrap();
1678+
let executor = TaskExecutor::new(
1679+
Arc::new(AgentRegistry::new()),
1680+
Arc::new(SchemaCoercionClient),
1681+
workspace.path().to_string_lossy().to_string(),
1682+
);
1683+
let spec = AgentStepSpec::new("step-1", "general", "assess", "Assess the thing.")
1684+
.with_output_schema(verdict_schema());
1685+
1686+
let outcome = executor.execute_step(spec, None).await;
1687+
1688+
assert!(outcome.success, "step should succeed: {}", outcome.output);
1689+
assert_eq!(
1690+
outcome.structured,
1691+
Some(serde_json::json!({ "verdict": "ok" })),
1692+
"a schema'd step returns the validated object in `structured`"
1693+
);
1694+
}
1695+
1696+
#[tokio::test]
1697+
async fn execute_step_without_schema_has_no_structured_output() {
1698+
let workspace = tempfile::tempdir().unwrap();
1699+
let executor = TaskExecutor::new(
1700+
Arc::new(AgentRegistry::new()),
1701+
Arc::new(SchemaCoercionClient),
1702+
workspace.path().to_string_lossy().to_string(),
1703+
);
1704+
let spec = AgentStepSpec::new("step-2", "general", "assess", "Assess the thing.");
1705+
1706+
let outcome = executor.execute_step(spec, None).await;
1707+
1708+
assert!(outcome.success, "step should succeed: {}", outcome.output);
1709+
assert_eq!(
1710+
outcome.structured, None,
1711+
"no schema requested → no structured output, no coercion call"
1712+
);
1713+
}
1714+
15731715
struct StaticLlmClient {
15741716
text: String,
15751717
}

0 commit comments

Comments
 (0)