@@ -63,6 +63,7 @@ pub struct TaskUpdate {
6363 pub priority : Option < Priority > ,
6464 pub due_date : Option < NaiveDate > ,
6565 pub dependencies : Vec < String > ,
66+ pub parent_id : Option < String > ,
6667}
6768
6869#[ derive( Debug , Clone ) ]
@@ -85,6 +86,8 @@ pub enum TriageAction {
8586 } ,
8687 /// AI wants to remember a fact about the user (pending confirmation).
8788 RememberFact ( String ) ,
89+ /// AI responded with text only (no tool call).
90+ Chat ( String ) ,
8891}
8992
9093#[ derive( Debug , Clone ) ]
@@ -581,14 +584,20 @@ fn call_llm(cfg: &LlmConfig, system: &str, user: &str) -> Result<String, String>
581584 }
582585}
583586
584- /// Send a prompt with tool definitions and return the first tool call.
585- /// Returns (tool_name, parsed_arguments) on success.
587+ /// Result of an LLM call with tools: either a tool call or text-only response.
588+ enum ToolCallResult {
589+ Call ( String , serde_json:: Value ) ,
590+ TextOnly ( String ) ,
591+ }
592+
593+ /// Send a prompt with tool definitions. Uses `tool_choice: auto` so the model
594+ /// can reason before acting. Returns a tool call or a text-only response.
586595fn call_llm_with_tools (
587596 cfg : & LlmConfig ,
588597 system : & str ,
589598 user : & str ,
590599 tools : & serde_json:: Value ,
591- ) -> Result < ( String , serde_json :: Value ) , String > {
600+ ) -> Result < ToolCallResult , String > {
592601 let body = match cfg. provider {
593602 Provider :: OpenAi => json ! ( {
594603 "model" : cfg. model,
@@ -597,7 +606,7 @@ fn call_llm_with_tools(
597606 { "role" : "user" , "content" : user}
598607 ] ,
599608 "tools" : tools,
600- "tool_choice" : "required "
609+ "tool_choice" : "auto "
601610 } ) ,
602611 Provider :: Anthropic => json ! ( {
603612 "model" : cfg. model,
@@ -606,47 +615,53 @@ fn call_llm_with_tools(
606615 "messages" : [
607616 { "role" : "user" , "content" : user}
608617 ] ,
609- "tools" : tools,
610- "tool_choice" : { "type" : "any" }
618+ "tools" : tools
611619 } ) ,
612620 } ;
613-
614621 let text = with_retry ( |attempt| {
615622 let timeout = scaled_timeout ( cfg. timeout , & body, attempt) ;
616623 send_llm_request ( cfg, & body, timeout)
617624 } ) ?;
618-
619625 match cfg. provider {
620626 Provider :: OpenAi => {
621627 let chat: ChatResponse = serde_json:: from_str ( & text)
622628 . map_err ( |err| format ! ( "AI JSON parse failed: {err}" ) ) ?;
623- let tool_call = chat
629+ let choice = chat
624630 . choices
625631 . first ( )
626- . and_then ( |c| c. message . tool_calls . as_ref ( ) )
627- . and_then ( |tc| tc. first ( ) )
628- . ok_or_else ( || "AI returned no tool call" . to_string ( ) ) ?;
629- let args: serde_json:: Value = serde_json:: from_str ( & tool_call. function . arguments )
630- . map_err ( |err| format ! ( "AI tool args parse failed: {err}" ) ) ?;
631- Ok ( ( tool_call. function . name . clone ( ) , args) )
632+ . ok_or_else ( || "AI returned no choices" . to_string ( ) ) ?;
633+ if let Some ( tool_calls) = & choice. message . tool_calls {
634+ if let Some ( tc) = tool_calls. first ( ) {
635+ let args: serde_json:: Value = serde_json:: from_str ( & tc. function . arguments )
636+ . map_err ( |err| format ! ( "AI tool args parse failed: {err}" ) ) ?;
637+ return Ok ( ToolCallResult :: Call ( tc. function . name . clone ( ) , args) ) ;
638+ }
639+ }
640+ let content = choice. message . content . as_deref ( ) . unwrap_or ( "" ) . to_string ( ) ;
641+ Ok ( ToolCallResult :: TextOnly ( content) )
632642 }
633643 Provider :: Anthropic => {
634644 let resp: AnthropicResponse = serde_json:: from_str ( & text)
635645 . map_err ( |err| format ! ( "AI JSON parse failed: {err}" ) ) ?;
636- let tool_block = resp
646+ if let Some ( tool_block) = resp. content . iter ( ) . find ( |b| b. block_type == "tool_use" ) {
647+ let name = tool_block
648+ . name
649+ . clone ( )
650+ . ok_or_else ( || "tool_use block missing name" . to_string ( ) ) ?;
651+ let input = tool_block
652+ . input
653+ . clone ( )
654+ . unwrap_or ( serde_json:: Value :: Object ( serde_json:: Map :: new ( ) ) ) ;
655+ return Ok ( ToolCallResult :: Call ( name, input) ) ;
656+ }
657+ let content: String = resp
637658 . content
638659 . iter ( )
639- . find ( |b| b. block_type == "tool_use" )
640- . ok_or_else ( || "AI returned no tool_use block" . to_string ( ) ) ?;
641- let name = tool_block
642- . name
643- . clone ( )
644- . ok_or_else ( || "tool_use block missing name" . to_string ( ) ) ?;
645- let input = tool_block
646- . input
647- . clone ( )
648- . unwrap_or ( serde_json:: Value :: Object ( serde_json:: Map :: new ( ) ) ) ;
649- Ok ( ( name, input) )
660+ . filter ( |b| b. block_type == "text" )
661+ . filter_map ( |b| b. text . as_deref ( ) )
662+ . collect :: < Vec < _ > > ( )
663+ . join ( "" ) ;
664+ Ok ( ToolCallResult :: TextOnly ( content) )
650665 }
651666 }
652667}
@@ -1101,6 +1116,7 @@ struct UpdateTaskArgs {
11011116 due_date : Option < String > ,
11021117 dependencies : Option < Vec < String > > ,
11031118 subtasks : Option < Vec < SubTaskArg > > ,
1119+ parent_id : Option < String > ,
11041120}
11051121
11061122#[ derive( Debug , Deserialize ) ]
@@ -1191,7 +1207,7 @@ fn triage_tool_defs(provider: Provider, bucket_names: &[String]) -> serde_json::
11911207 make_tool_def(
11921208 provider,
11931209 "create_task" ,
1194- "Create a new task. Use ONLY when the user describes genuinely new work that does NOT overlap with any existing task or sub-task. Always check existing tasks first ." ,
1210+ "Create a genuinely NEW task. ONLY use after confirming no existing task or sub-task covers the same work. If similar work exists, use update_task instead ." ,
11951211 json!( {
11961212 "type" : "object" ,
11971213 "properties" : {
@@ -1218,7 +1234,7 @@ fn triage_tool_defs(provider: Provider, bucket_names: &[String]) -> serde_json::
12181234 make_tool_def(
12191235 provider,
12201236 "update_task" ,
1221- "Update an existing task. PREFER this over create_task when similar work already exists. Use for status changes, field updates, or adding sub-tasks." ,
1237+ "Update an existing task. ALWAYS prefer this over create_task when similar work exists. Use for: status changes, field updates, adding sub-tasks, or moving a task under a new parent (set parent_id) ." ,
12221238 json!( {
12231239 "type" : "object" ,
12241240 "properties" : {
@@ -1238,7 +1254,8 @@ fn triage_tool_defs(provider: Provider, bucket_names: &[String]) -> serde_json::
12381254 "type" : "array" ,
12391255 "items" : st. clone( ) ,
12401256 "description" : "Sub-tasks to create under this task"
1241- }
1257+ } ,
1258+ "parent_id" : { "type" : "string" , "description" : "id_prefix of the new parent task to move this task under, or 'none' to promote to a root task" }
12421259 } ,
12431260 "required" : [ "target_id" ]
12441261 } )
@@ -1379,27 +1396,35 @@ fn triage_task(cfg: &LlmConfig, job: &AiJob, raw_input: &str) -> AiResult {
13791396
13801397 let today = Local :: now ( ) . format ( "%Y-%m-%d" ) . to_string ( ) ;
13811398 let mut system = format ! (
1382- "Today is {today}. You are an expert AI project manager. Analyze the user's message and call \
1383- the appropriate tool.\n \
1384- Rules:\n \
1385- - CRITICAL: Before creating ANY task, carefully check ALL existing tasks AND their sub-tasks \
1386- (lines starting with ↳). If a task or sub-task with similar meaning already exists, use \
1387- update_task instead of create_task. NEVER create a duplicate.\n \
1388- - When adding sub-tasks, check the parent's existing sub-tasks first. Do NOT create sub-tasks \
1389- that overlap with ones already listed.\n \
1390- - Generate clean, actionable titles (do NOT use the user's raw words verbatim).\n \
1391- - Infer progress from context (e.g. \" already working on X\" → \" In progress\" ).\n \
1399+ "Today is {today}. You are an expert AI project manager.\n \n \
1400+ WORKFLOW — Follow these steps for EVERY request:\n \
1401+ 1. SCAN: Read through ALL existing tasks and sub-tasks (lines starting with ↳) below. \
1402+ Note each task's id_prefix, title, bucket, and parent-child relationships.\n \
1403+ 2. IDENTIFY: Find which existing tasks relate to the user's request. \
1404+ Look for tasks with similar meaning, purpose, or scope — even if worded differently. \
1405+ Check both parent tasks and their sub-tasks.\n \
1406+ 3. REASON: Decide the correct action:\n \
1407+ - If the user refers to existing work → update_task (NEVER create a duplicate)\n \
1408+ - If the user wants to reorganize or move tasks → update_task with parent_id\n \
1409+ - If the user wants to break down a task → decompose_task\n \
1410+ - If the user describes genuinely NEW work not covered by ANY existing task → create_task\n \
1411+ - If the user wants to remove a task → delete_task\n \
1412+ 4. ACT: Call exactly one tool with the correct parameters.\n \n \
1413+ RULES:\n \
1414+ - NEVER create a task when one with similar meaning already exists. Use update_task instead.\n \
1415+ - When the user says 'make X part of Y' or 'move X under Y', use update_task on task X \
1416+ with parent_id set to Y's id_prefix. Do NOT create new tasks.\n \
1417+ - When adding sub-tasks, check the parent's existing sub-tasks first. Do NOT create \
1418+ sub-tasks that overlap with ones already listed.\n \
1419+ - Generate clean, actionable titles (do NOT copy the user's raw words verbatim).\n \
1420+ - Infer progress from context (e.g. 'already working on X' → 'In progress').\n \
13921421 - If the user asks to break down, decompose, split, or create sub-tasks, use decompose_task.\n \
1393- - When updating a task with multiple items/links that map to sub-tasks, include them in the subtasks array.\n \
1394- - NEVER put sub-task breakdowns or numbered lists into the description field. Use the subtasks array.\n \
1422+ - NEVER put sub-task lists or numbered breakdowns into the description field. Use the subtasks array.\n \
13951423 - Subtasks inherit the parent task's bucket and priority unless specified otherwise.\n \
1396- - For delete: just call delete_task with the target_id.\n \
13971424 - When the user mentions a task (by name or reference) and follows with an instruction, \
1398- assume the instruction applies to that task or its subtasks — use update_task or \
1399- decompose_task targeting that task rather than creating something new.\n \
1400- - If the user shares something durable and broadly useful about themselves (name, role, team, \
1401- strong preference), call remember_fact. Do NOT remember task-specific details."
1402- ) ;
1425+ assume it applies to THAT task — use update_task or decompose_task, not create_task.\n \
1426+ - If the user shares something durable about themselves (name, role, team, preference), \
1427+ call remember_fact. Do NOT remember task-specific details." ) ;
14031428 if !job. user_profile . is_empty ( ) {
14041429 system. push_str ( & format ! ( "\n \n User profile: {}" , job. user_profile) ) ;
14051430 }
@@ -1436,11 +1461,29 @@ fn triage_task(cfg: &LlmConfig, job: &AiJob, raw_input: &str) -> AiResult {
14361461
14371462 let tools = triage_tool_defs ( cfg. provider , & job. bucket_names ) ;
14381463
1439- let ( tool_name , args ) = match call_llm_with_tools ( cfg, & system, & user_prompt, & tools) {
1464+ let tool_result = match call_llm_with_tools ( cfg, & system, & user_prompt, & tools) {
14401465 Ok ( result) => result,
14411466 Err ( err) => return err_result ( err) ,
14421467 } ;
14431468
1469+ let ( tool_name, args) = match tool_result {
1470+ ToolCallResult :: Call ( name, args) => ( name, args) ,
1471+ ToolCallResult :: TextOnly ( text) => {
1472+ let reply = if text. trim ( ) . is_empty ( ) {
1473+ "No action taken." . to_string ( )
1474+ } else {
1475+ text
1476+ } ;
1477+ return AiResult {
1478+ task_id : job. task_id ,
1479+ update : TaskUpdate :: default ( ) ,
1480+ error : None ,
1481+ triage_action : Some ( TriageAction :: Chat ( reply) ) ,
1482+ sub_task_specs : Vec :: new ( ) ,
1483+ } ;
1484+ }
1485+ } ;
1486+
14441487 let allowed: HashSet < String > = job. context . iter ( ) . map ( |t| short_id ( t. id ) ) . collect ( ) ;
14451488
14461489 match tool_name. as_str ( ) {
@@ -1475,6 +1518,7 @@ fn triage_task(cfg: &LlmConfig, job: &AiJob, raw_input: &str) -> AiResult {
14751518 . as_deref ( )
14761519 . and_then ( |s| NaiveDate :: parse_from_str ( s. trim ( ) , "%Y-%m-%d" ) . ok ( ) ) ,
14771520 dependencies : resolve_deps ( parsed. dependencies , & allowed) ,
1521+ parent_id : None ,
14781522 } ,
14791523 error : None ,
14801524 triage_action : Some ( TriageAction :: Create ) ,
@@ -1488,16 +1532,21 @@ fn triage_task(cfg: &LlmConfig, job: &AiJob, raw_input: &str) -> AiResult {
14881532 } ;
14891533 let target = parsed. target_id . trim ( ) . to_string ( ) ;
14901534 let sub_task_specs = parse_subtask_args ( parsed. subtasks , & job. bucket_names ) ;
1491- let triage_action = if target. is_empty ( ) {
1492- Some ( TriageAction :: Create )
1493- } else {
1494- Some ( TriageAction :: Update ( target) )
1495- } ;
1496- let is_edit = matches ! ( & triage_action, Some ( TriageAction :: Update ( _) ) ) ;
1535+ if target. is_empty ( ) {
1536+ return err_result (
1537+ "update_task called without target_id — specify which task to update"
1538+ . to_string ( ) ,
1539+ ) ;
1540+ }
1541+ let parent_id = parsed
1542+ . parent_id
1543+ . as_deref ( )
1544+ . map ( |s| s. trim ( ) . to_string ( ) )
1545+ . filter ( |s| !s. is_empty ( ) ) ;
14971546 AiResult {
14981547 task_id : job. task_id ,
14991548 update : TaskUpdate {
1500- is_edit,
1549+ is_edit : false ,
15011550 title : parsed
15021551 . title
15031552 . as_deref ( )
@@ -1527,9 +1576,10 @@ fn triage_task(cfg: &LlmConfig, job: &AiJob, raw_input: &str) -> AiResult {
15271576 . as_deref ( )
15281577 . and_then ( |s| NaiveDate :: parse_from_str ( s. trim ( ) , "%Y-%m-%d" ) . ok ( ) ) ,
15291578 dependencies : resolve_deps ( parsed. dependencies , & allowed) ,
1579+ parent_id,
15301580 } ,
15311581 error : None ,
1532- triage_action,
1582+ triage_action : Some ( TriageAction :: Update ( target ) ) ,
15331583 sub_task_specs,
15341584 }
15351585 }
0 commit comments