Skip to content

Commit cd8fd80

Browse files
committed
Add final_message_json tool and relax phase decision parser
- oai-runner: inject final_message_json tool when response_schema is provided - oai-runner: skip schema-in-prompt and response_format when tool is active - oai-runner: prompt model to call tool if it responds with text instead - workflow-runner: default kind to "phase_decision" when missing from JSON - workflow-runner: accept {"verdict":"advance","reason":"..."} without kind field
1 parent 53fa8d8 commit cd8fd80

6 files changed

Lines changed: 108 additions & 14 deletions

File tree

.ao/tasks.db

Whitespace-only changes.

.claude/settings.local.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,10 @@
143143
"Bash(git tag:*)",
144144
"Bash(for d:*)",
145145
"Bash(npm run:*)",
146-
"Bash(gh api:*)"
146+
"Bash(gh api:*)",
147+
"Bash(cargo generate-lockfile:*)",
148+
"Bash(cargo ao-fmt:*)",
149+
"Bash(bash /Users/samishukri/.local/bin/ao-fleet-monitor.sh 2>&1)"
147150
]
148151
},
149152
"enableAllProjectMcpServers": true,

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/oai-runner/src/runner/agent_loop.rs

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,11 @@ pub async fn run_agent_loop(
141141
}
142142
}
143143

144-
let needs_schema_in_prompt =
145-
structured_output == Some(StructuredOutputSupport::JsonObjectOnly) && response_schema.is_some();
144+
let has_final_message_tool = response_schema.is_some();
145+
146+
let needs_schema_in_prompt = structured_output == Some(StructuredOutputSupport::JsonObjectOnly)
147+
&& response_schema.is_some()
148+
&& !has_final_message_tool;
146149

147150
if messages.is_empty() {
148151
let mut sys = system_prompt.to_string();
@@ -168,9 +171,37 @@ pub async fn run_agent_loop(
168171
tool_call_id: None,
169172
});
170173

174+
let mut tools_with_final: Vec<ToolDefinition> = tools.to_vec();
175+
if has_final_message_tool {
176+
let schema_desc = response_schema.and_then(|s| serde_json::to_string(s).ok()).unwrap_or_default();
177+
tools_with_final.push(ToolDefinition {
178+
type_: "function".to_string(),
179+
function: FunctionSchema {
180+
name: "final_message_json".to_string(),
181+
description: format!(
182+
"Call this tool to submit your final structured JSON result when your task is complete. \
183+
The message MUST be valid JSON matching this schema: {}",
184+
schema_desc
185+
),
186+
parameters: serde_json::json!({
187+
"type": "object",
188+
"properties": {
189+
"message": {
190+
"type": "string",
191+
"description": "Your final result as a JSON string matching the required schema."
192+
}
193+
},
194+
"required": ["message"],
195+
"additionalProperties": false
196+
}),
197+
},
198+
});
199+
}
200+
let effective_tools = if has_final_message_tool { &tools_with_final } else { tools };
201+
171202
let needs_tool_name_sanitization = model.contains("kimi");
172203
let sanitized_tools: Vec<ToolDefinition> = if needs_tool_name_sanitization {
173-
tools
204+
effective_tools
174205
.iter()
175206
.map(|t| {
176207
let mut t = t.clone();
@@ -179,9 +210,9 @@ pub async fn run_agent_loop(
179210
})
180211
.collect()
181212
} else {
182-
tools.to_vec()
213+
effective_tools.to_vec()
183214
};
184-
let api_tools = if needs_tool_name_sanitization { &sanitized_tools } else { tools };
215+
let api_tools = if needs_tool_name_sanitization { &sanitized_tools } else { effective_tools };
185216

186217
for turn in 0..max_turns {
187218
if cancel_token.is_cancelled() {
@@ -197,12 +228,16 @@ pub async fn run_agent_loop(
197228

198229
context::truncate_to_fit(&mut messages, context_limit, max_tokens);
199230

200-
let format = match structured_output {
201-
Some(StructuredOutputSupport::JsonSchema) => response_schema.map(build_json_schema_format),
202-
Some(StructuredOutputSupport::JsonObjectOnly) if response_schema.is_some() => {
203-
Some(build_json_object_format())
231+
let format = if has_final_message_tool {
232+
None // Don't send response_format when using final_message_json tool
233+
} else {
234+
match structured_output {
235+
Some(StructuredOutputSupport::JsonSchema) => response_schema.map(build_json_schema_format),
236+
Some(StructuredOutputSupport::JsonObjectOnly) if response_schema.is_some() => {
237+
Some(build_json_object_format())
238+
}
239+
_ => None,
204240
}
205-
_ => None,
206241
};
207242

208243
let request = ChatRequest {
@@ -234,6 +269,21 @@ pub async fn run_agent_loop(
234269
}
235270

236271
if !has_tool_calls {
272+
// If final_message_json tool is available but model didn't call it, prompt to call it
273+
if has_final_message_tool && turn < max_turns - 1 {
274+
messages.push(ChatMessage {
275+
reasoning_content: None,
276+
role: "user".to_string(),
277+
content: Some(
278+
"You must call the final_message_json tool to submit your result. \
279+
Do not respond with plain text — call the final_message_json tool now."
280+
.to_string(),
281+
),
282+
tool_calls: None,
283+
tool_call_id: None,
284+
});
285+
continue;
286+
}
237287
output.flush_result();
238288
let content = assistant_msg.content.as_deref().unwrap_or("");
239289
let mut schema_ok = true;
@@ -279,6 +329,31 @@ pub async fn run_agent_loop(
279329

280330
let tool_calls = assistant_msg.tool_calls.as_ref().unwrap();
281331

332+
// Check if any tool call is final_message_json — if so, emit and stop
333+
if has_final_message_tool {
334+
let final_tc = tool_calls.iter().find(|tc| {
335+
let name = if needs_tool_name_sanitization {
336+
tc.function.name.replace('_', ".")
337+
} else {
338+
tc.function.name.clone()
339+
};
340+
name == "final_message_json" || name == "final.message.json"
341+
});
342+
if let Some(tc) = final_tc {
343+
let args: serde_json::Value =
344+
serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null);
345+
let message = args.get("message").and_then(|v| v.as_str()).unwrap_or("");
346+
output.text_chunk(message);
347+
output.flush_result();
348+
if let Some(sid) = session_id {
349+
let _ = save_session_messages_to(&config_dir(), sid, &messages);
350+
}
351+
output.emit_session_summary();
352+
output.newline();
353+
return Ok(());
354+
}
355+
}
356+
282357
for tc in tool_calls {
283358
if cancel_token.is_cancelled() {
284359
eprintln!("[oai-runner] Cancelled between tool calls");

crates/orchestrator-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "orchestrator-cli"
3-
version = "0.2.2"
3+
version = "0.2.3"
44
edition = "2021"
55

66
[[bin]]

crates/workflow-runner-v2/src/payload_traversal.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ fn extract_phase_decision(payload: &Value, phase_id: &str) -> Option<orchestrato
7373
}
7474

7575
fn try_parse_decision(value: &Value, phase_id: &str) -> Option<orchestrator_core::PhaseDecision> {
76-
let kind = value.get("kind").and_then(Value::as_str).unwrap_or("");
76+
let kind = value.get("kind").and_then(Value::as_str).unwrap_or("phase_decision");
7777
if !kind.eq_ignore_ascii_case("phase_decision") {
7878
return None;
7979
}
@@ -201,6 +201,22 @@ mod tests {
201201
assert_eq!(decision.verdict, orchestrator_core::PhaseDecisionVerdict::Skip);
202202
}
203203

204+
#[test]
205+
fn parse_phase_decision_without_kind() {
206+
let text = r#"{"verdict":"advance","reason":"All tests pass","confidence":0.95}"#;
207+
let decision = parse_phase_decision_from_text(text, "implementation").unwrap();
208+
assert_eq!(decision.verdict, orchestrator_core::PhaseDecisionVerdict::Advance);
209+
assert_eq!(decision.reason, "All tests pass");
210+
assert!((decision.confidence - 0.95).abs() < f32::EPSILON);
211+
assert_eq!(decision.kind, "phase_decision");
212+
}
213+
214+
#[test]
215+
fn parse_phase_decision_rejects_wrong_kind() {
216+
let text = r#"{"kind":"something_else","verdict":"advance","reason":"test"}"#;
217+
assert!(parse_phase_decision_from_text(text, "implementation").is_none());
218+
}
219+
204220
#[test]
205221
fn parse_commit_message_from_nested() {
206222
let text = r#"{"kind":"implementation_result","commit_message":"feat: add feature","phase_decision":{"kind":"phase_decision","verdict":"advance"}}"#;

0 commit comments

Comments
 (0)