Skip to content

Commit e4f6eab

Browse files
author
ComputelessComputer
committed
add scan-reason-act AI triage with task reparenting support
1 parent 182947d commit e4f6eab

2 files changed

Lines changed: 240 additions & 72 deletions

File tree

src/llm.rs

Lines changed: 107 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
586595
fn 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\nUser 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

Comments
 (0)