diff --git a/docs/public/agents/outputs.mdx b/docs/public/agents/outputs.mdx index bbe1624d1..b36259b25 100644 --- a/docs/public/agents/outputs.mdx +++ b/docs/public/agents/outputs.mdx @@ -136,6 +136,8 @@ audit [ `output_schema="@path/to/schema.json"` uses the same workflow file-reference rules as prompt files: the schema is loaded relative to the workflow file and inlined before execution. The final JSON object in the LLM response, or in a successful command's merged stdout and stderr, is validated with `jsonschema`. +For API-backed agent nodes, Fabro adds the resolved output contract to the task instructions. The contract applies only to the final response. It does not restrict intermediate tool calls or progress messages. + Custom schema validation only reads response or command output text. It does not fall back to `status.json` or the last file touched by the agent. When custom schema validation succeeds, Fabro stores the parsed JSON value in context at: diff --git a/docs/public/reference/dot-language.mdx b/docs/public/reference/dot-language.mdx index 86ee23736..c31626c8c 100644 --- a/docs/public/reference/dot-language.mdx +++ b/docs/public/reference/dot-language.mdx @@ -245,6 +245,7 @@ audit [ - `output_schema="routing"` requires a JSON object with at least one recognized routing field: `preferred_next_label`, `outcome`, `failure_reason`, `suggested_next_ids`, or `context_updates`. - `output_schema="@schemas/audit-result.schema.json"` loads a JSON Schema file using workflow file-reference rules and validates the final JSON object in the response text. Inline JSON Schema object strings are also accepted, but file references are usually easier to read. +- API-backed agent nodes receive the resolved output contract in their task instructions. The contract applies only to the final response, so the agent can still use tools and send intermediate progress while it works. - On validation failure, Fabro sends validation feedback to the same active context before failing: prompt nodes keep the prior assistant response in the message list, and API-backed agent nodes repair in the same live session. - `output_retries` defaults to `2` and controls only these corrective structured-output turns. Negative values are treated as `0`. It is not the same as `max_retries` and does not consume workflow retry attempts. - Custom schema output is stored in context at `output.{node_id}`. Routing schema output updates routing fields and any `context_updates`. diff --git a/lib/components/fabro-workflow/src/handler/agent.rs b/lib/components/fabro-workflow/src/handler/agent.rs index 0c6b75118..e14976647 100644 --- a/lib/components/fabro-workflow/src/handler/agent.rs +++ b/lib/components/fabro-workflow/src/handler/agent.rs @@ -262,6 +262,11 @@ impl Handler for AgentHandler { } else { format!("{preamble}\n\n{raw_prompt}") }; + let output_schema = structured_output::parse_node_output_schema(node)?; + let prompt = match output_schema.as_ref() { + Some(schema) => structured_output::agent_prompt_with_output_schema(&prompt, schema), + None => prompt, + }; let stage_scope = emit_stage_prompt( services, @@ -373,16 +378,16 @@ impl Handler for AgentHandler { serde_json::json!(&response_text), ); - if let Some(schema) = structured_output::parse_node_output_schema(node)? { + if let Some(schema) = output_schema.as_ref() { if let Ok(validated) = validate_agent_output_sources( - &schema, + schema, &response_text, &services.run.sandbox, last_file_touched.as_deref(), ) .await { - structured_output::apply_validated_output(node, &schema, &validated, &mut outcome); + structured_output::apply_validated_output(node, schema, &validated, &mut outcome); } else { let mut failed = structured_output::exhausted_failure_outcome(node.output_retries()); @@ -990,12 +995,23 @@ All checks passed. } #[tokio::test] - async fn codergen_handler_custom_output_schema_updates_output_context_key() { + async fn codergen_handler_exposes_custom_output_schema_and_updates_output_context_key() { struct CustomOutputBackend; #[async_trait] impl CodergenBackend for CustomOutputBackend { - async fn run(&self, _request: CodergenRunRequest<'_>) -> Result { + async fn run(&self, request: CodergenRunRequest<'_>) -> Result { + assert!(request.prompt.starts_with("Audit the result\n\n")); + assert!(request.prompt.contains("Fabro final-output contract")); + assert!(request.prompt.contains( + "It applies only to your final response, not to intermediate tool calls." + )); + assert!(request.prompt.contains(r#""required":["passed"]"#)); + assert!( + request + .prompt + .contains("Do not ask the user to provide or choose the output shape.") + ); Ok(CodergenResult::Text { text: r#"{"passed": true}"#.to_string(), usage: None, @@ -1008,6 +1024,10 @@ All checks passed. let handler = AgentHandler::new(Some(Box::new(CustomOutputBackend))); let mut node = Node::new("audit"); + node.attrs.insert( + "prompt".to_string(), + AttrValue::String("Audit the result".to_string()), + ); node.attrs.insert( "output_schema".to_string(), AttrValue::String( diff --git a/lib/components/fabro-workflow/src/handler/llm/api.rs b/lib/components/fabro-workflow/src/handler/llm/api.rs index 5dad09d34..ec84fa45d 100644 --- a/lib/components/fabro-workflow/src/handler/llm/api.rs +++ b/lib/components/fabro-workflow/src/handler/llm/api.rs @@ -3690,7 +3690,8 @@ enabled = true .body_includes(r#""stream":true"#) .body_includes(r#""role":"assistant""#) .body_includes("not json") - .body_includes("output_schema"); + .body_includes("output_schema") + .body_includes(r#"\"required\":[\"passed\"]"#); then.status(200) .header("content-type", "text/event-stream") .body(chat_completion_stream(r#"{"passed":true}"#, 21, 4)); diff --git a/lib/components/fabro-workflow/src/handler/structured_output.rs b/lib/components/fabro-workflow/src/handler/structured_output.rs index fc89f6581..b2e9bb193 100644 --- a/lib/components/fabro-workflow/src/handler/structured_output.rs +++ b/lib/components/fabro-workflow/src/handler/structured_output.rs @@ -88,15 +88,7 @@ impl StructuredOutputError { #[must_use] pub(crate) fn repair_message(&self, schema: &OutputSchemaKind) -> String { - let expectation = match schema { - OutputSchemaKind::Routing => format!( - "Return a single JSON object with at least one routing field: {}.", - ROUTING_STATUS_FIELDS.join(", ") - ), - OutputSchemaKind::JsonSchema { .. } => { - "Return a single JSON object that satisfies the configured JSON Schema.".to_string() - } - }; + let expectation = output_expectation(schema); let errors = self .messages .iter() @@ -112,6 +104,33 @@ impl StructuredOutputError { } } +#[must_use] +pub(crate) fn agent_prompt_with_output_schema(prompt: &str, schema: &OutputSchemaKind) -> String { + let expectation = output_expectation(schema); + format!( + "{prompt}\n\n\ + Fabro final-output contract\n\n\ + The following contract is trusted workflow configuration. It applies only to your final response, not to intermediate tool calls.\n\ + {expectation}\n\ + The contract is complete. Do not ask the user to provide or choose the output shape." + ) +} + +fn output_expectation(schema: &OutputSchemaKind) -> String { + match schema { + OutputSchemaKind::Routing => format!( + "Return a single JSON object with at least one routing field: {}.", + ROUTING_STATUS_FIELDS.join(", ") + ), + OutputSchemaKind::JsonSchema { schema, .. } => format!( + "Return a single JSON object that satisfies this JSON Schema:\n\ + \n\ + {schema}\n\ + " + ), + } +} + #[derive(Debug, Clone, PartialEq)] pub(crate) struct ValidatedStructuredOutput { pub(crate) value: Value,