Skip to content
Merged

Dev #26

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion crates/chatcmd-mcp/src/server_contract.rs

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src/runtime_host/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,15 @@ impl RuntimeHost {
.await
}
"agent_subagent_start" => {
if !self
.subagent_delegation_explicitly_requested(&context)
.await?
{
return Err(RuntimeError::new(
"subagent_explicit_user_intent_required",
"The current root user turn did not explicitly request multi-agent delegation. Continue in the current conversation instead of opening a child.",
));
}
let input: SubagentStartInput = parse(arguments)?;
super::subagent_contract::validate_delegation_contract(&input)?;
self.register_subagent(
Expand Down
39 changes: 34 additions & 5 deletions src/runtime_host/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,22 @@ impl RuntimeHost {
} else {
None
};
let unresolved_openai_tool_call = first_user_message.is_none()
&& explicit_task.is_none()
&& bound_task.is_none()
&& delegated_task.is_none()
&& chatgpt_bridge_task.is_none()
&& mapped_scope_task.is_none()
&& pending_chatgpt_bridge_task.is_none()
&& conversation_scope
.as_deref()
.is_some_and(|scope| scope.starts_with("openai:"));
if unresolved_openai_tool_call {
return Err(RuntimeError::new(
"conversation_identity_unbound",
"this ChatGPT tool call is not bound to an existing local conversation. Call agent_user_message first and reuse its taskId/turnId instead of creating a new conversation",
));
}
let task = explicit_task.unwrap_or_else(|| {
delegated_task.unwrap_or_else(|| {
chatgpt_bridge_task.unwrap_or_else(|| {
Expand Down Expand Up @@ -304,7 +320,18 @@ impl RuntimeHost {
conversation_scope: &str,
) -> RuntimeResult<Option<String>> {
sqlx::query_scalar::<_, String>(
"SELECT id FROM tasks WHERE agent_id=? AND conversation_scope_hash=? ORDER BY created_at_ms,id LIMIT 1",
r#"SELECT t.id
FROM tasks t
WHERE t.agent_id=? AND t.conversation_scope_hash=?
AND (
t.source='chatgpt_web'
OR EXISTS(
SELECT 1 FROM timeline_events e
WHERE e.task_id=t.id AND e.actor='user' AND e.kind='message'
)
)
ORDER BY t.created_at_ms,t.id
LIMIT 1"#,
)
.bind(agent_id)
.bind(conversation_scope)
Expand Down Expand Up @@ -354,20 +381,22 @@ impl RuntimeHost {
r#"SELECT r.task_id
FROM chatgpt_bridge_requests r
JOIN tasks t ON t.id=r.task_id
LEFT JOIN chatgpt_conversations c ON c.task_id=r.task_id
WHERE r.task_id IS NOT NULL
AND t.agent_id=? AND t.source='chatgpt_web'
AND t.agent_id=? AND t.source='chatgpt_web' AND t.status='running'
AND r.status IN ('queued','running','stop_requested')
AND r.updated_at_ms>=?
AND (c.active_request_id=r.id OR r.updated_at_ms>=?)
GROUP BY r.task_id
ORDER BY MAX(r.updated_at_ms) DESC
ORDER BY MAX(CASE WHEN c.active_request_id=r.id THEN 1 ELSE 0 END) DESC,
MAX(r.updated_at_ms) DESC
LIMIT 2"#,
)
.bind(agent_id)
.bind(cutoff)
.fetch_all(self.repository.pool())
.await
.map_err(|_| {
RuntimeError::new("storage_error", "pending ChatGPT bridge task lookup failed")
RuntimeError::new("storage_error", "active ChatGPT bridge task lookup failed")
})?;
Ok((rows.len() == 1).then(|| rows[0].clone()))
}
Expand Down
14 changes: 13 additions & 1 deletion src/runtime_host/subagent_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use chatcmd_mcp::catalog_hash;
use chatcmd_runtime::OperationContext;
use chatcmd_runtime::{OperationContext, ShellCreateRequest};
use serde_json::{Value, json};
use sqlx::Row as _;
use tempfile::TempDir;
Expand All @@ -26,6 +26,18 @@ async fn parent_fixture() -> (RuntimeHost, OperationContext, TempDir) {
.execute(host.repository.pool())
.await
.expect("insert parent task");
sqlx::query(
"INSERT INTO timeline_events(event_id,task_id,turn_id,session_id,actor,kind,idempotency_key,payload_json,metadata_json,created_at_ms) VALUES(?,?,?,NULL,'user','message',?,?,NULL,?)",
)
.bind("event-subagent-parent-user")
.bind(PARENT_TASK_ID)
.bind(PARENT_TURN_ID)
.bind("subagent-parent-user-message")
.bind(json!({"role":"user","content":"Chia agent để chạy delegated test fixture"}).to_string())
.bind(now)
.execute(host.repository.pool())
.await
.expect("insert explicit parent user message");

let mut context =
OperationContext::new("subagent-parent-request", &agent_id, "agent_subagent_start");
Expand Down
2 changes: 1 addition & 1 deletion src/runtime_host/subagents/registration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ impl RuntimeHost {
));
}

sqlx::query("INSERT INTO tasks(id,agent_id,device_id,conversation_scope_hash,title,source,project_folder,status,active_session_id,generation,stopped_at_ms,created_at_ms,updated_at_ms) SELECT ?,agent_id,device_id,NULL,?,'mcp',project_folder,'pending',NULL,1,NULL,?,? FROM tasks WHERE id=? ON CONFLICT(id) DO NOTHING")
sqlx::query("INSERT INTO tasks(id,agent_id,device_id,conversation_scope_hash,title,source,project_folder,allow_execute,status,active_session_id,generation,stopped_at_ms,created_at_ms,updated_at_ms) SELECT ?,agent_id,device_id,NULL,?,'mcp',project_folder,allow_execute,'pending',NULL,1,NULL,?,? FROM tasks WHERE id=? ON CONFLICT(id) DO NOTHING")
.bind(&deterministic_task_id)
.bind(name)
.bind(now)
Expand Down
54 changes: 52 additions & 2 deletions src/runtime_host/user_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use super::{RuntimeHost, invalid, now_ms, storage_error};
mod intent;
#[path = "user_message_paths.rs"]
mod paths;
use intent::{intent_hint, is_plan_mode_request};
use intent::{intent_hint, is_explicit_multi_agent_request, is_plan_mode_request};
use paths::extract_explicit_absolute_paths;

impl RuntimeHost {
Expand Down Expand Up @@ -41,6 +41,48 @@ impl RuntimeHost {
}
}

pub(super) async fn subagent_delegation_explicitly_requested(
&self,
context: &OperationContext,
) -> RuntimeResult<bool> {
let task_id = required_task_id(context)?;
let turn_id = required_turn_id(context)?;
let root = sqlx::query_as::<_, (String, String)>(
r#"WITH RECURSIVE ancestors(parent_task_id,parent_turn_id,child_task_id,depth) AS (
SELECT parent_task_id,parent_turn_id,child_task_id,1 FROM subagent_runs WHERE child_task_id=?
UNION ALL
SELECT r.parent_task_id,r.parent_turn_id,r.child_task_id,ancestors.depth+1
FROM subagent_runs r JOIN ancestors ON r.child_task_id=ancestors.parent_task_id
WHERE ancestors.depth<32
)
SELECT parent_task_id,parent_turn_id FROM ancestors ORDER BY depth DESC LIMIT 1"#,
)
.bind(task_id.as_str())
.fetch_optional(self.repository.pool())
.await
.map_err(|_| RuntimeError::new("storage_error", "sub-agent root intent lookup failed"))?;
let (root_task_id, root_turn_id) =
root.unwrap_or_else(|| (task_id.as_str().to_owned(), turn_id.as_str().to_owned()));
let payload = sqlx::query_scalar::<_, String>(
"SELECT payload_json FROM timeline_events WHERE task_id=? AND turn_id=? AND actor='user' AND kind='message' ORDER BY created_at_ms,event_id LIMIT 1",
)
.bind(&root_task_id)
.bind(&root_turn_id)
.fetch_optional(self.repository.pool())
.await
.map_err(|_| RuntimeError::new("storage_error", "sub-agent root user message unavailable"))?;
let content = payload
.and_then(|payload| serde_json::from_str::<Value>(&payload).ok())
.and_then(|payload| {
payload
.get("content")
.and_then(Value::as_str)
.map(str::to_owned)
})
.unwrap_or_default();
Ok(is_explicit_multi_agent_request(&content))
}

pub(super) async fn task_user_path_scopes(
&self,
context: &OperationContext,
Expand Down Expand Up @@ -263,6 +305,7 @@ impl RuntimeHost {
Value::Null
};
let subagent_limit = self.subagent_concurrency_limit().await?;
let explicit_subagent_intent = is_explicit_multi_agent_request(content);
let intent_hint = intent_hint(content);
Ok(json!({
"accepted": true,
Expand All @@ -277,7 +320,14 @@ impl RuntimeHost {
},
"enabled": subagent_limit > 0,
"maxConcurrent": subagent_limit,
"instruction": if subagent_limit == 0 { "Sub-agents are disabled by the user. Do not call agent_subagent_start or delegate to any child; perform the work in this conversation." } else { "Use registered children within the global limit. All descendants remain attached to the root turn. If a nested child cannot acquire a slot, continue locally rather than waiting for another child." }
"explicitUserIntent": explicit_subagent_intent,
"instruction": if subagent_limit == 0 {
"Sub-agents are disabled by the user. Do not call agent_subagent_start or delegate to any child; perform the work in this conversation."
} else if explicit_subagent_intent {
"The current root user turn explicitly requested multi-agent work. Use registered children only for that requested delegation scope; all descendants remain attached to the root turn."
} else {
"The current root user turn did not explicitly request multi-agent delegation. Do not call agent_subagent_start or open a child conversation; continue in this conversation."
}
},
"planMode": is_plan_mode_request(content),
"intentHint": intent_hint,
Expand Down
105 changes: 105 additions & 0 deletions src/runtime_host/user_message_intent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,71 @@ pub(super) fn is_plan_mode_request(content: &str) -> bool {
|| normalized.split_whitespace().any(|word| word == "#plan"))
}

pub(super) fn is_explicit_multi_agent_request(content: &str) -> bool {
let normalized = intent_prose(content)
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let negated = [
"không chia agent",
"không cần chia agent",
"đừng chia agent",
"không thử chia agent",
"không cần thử chia agent",
"đừng thử chia agent",
"không muốn chia agent",
"không dùng nhiều agent",
"không sử dụng nhiều agent",
"do not split into agents",
"do not try to split into agents",
"don't try to split into agents",
"don't split into agents",
"do not use multiple agents",
"don't use multiple agents",
"without subagents",
"no subagents",
]
.iter()
.any(|phrase| normalized.contains(phrase));
if negated {
return false;
}

let starts_with_command = |command: &str| {
normalized.strip_prefix(command).is_some_and(|rest| {
rest.is_empty()
|| rest.starts_with(' ')
|| rest.starts_with(':')
|| rest.starts_with('-')
})
};

starts_with_command("chia agent")
|| ((starts_with_command("chia ra") || starts_with_command("tách ra"))
&& normalized.contains(" agent"))
|| starts_with_command("dùng nhiều agent")
|| starts_with_command("sử dụng nhiều agent")
|| starts_with_command("use multiple agents")
|| starts_with_command("use several agents")
|| starts_with_command("split into agents")
|| starts_with_command("split across agents")
|| [
"hãy chia agent",
"vui lòng chia agent",
"thử chia agent",
"giúp tôi chia agent",
"giúp t chia agent",
"hãy dùng nhiều agent",
"vui lòng dùng nhiều agent",
"hãy sử dụng nhiều agent",
"please use multiple agents",
"please split into agents",
]
.iter()
.any(|phrase| normalized.contains(phrase))
}

pub(super) fn intent_hint(content: &str) -> Value {
let normalized = intent_prose(content).to_lowercase();
let workflow_kind = if is_plan_mode_request(content) {
Expand Down Expand Up @@ -111,6 +176,46 @@ mod tests {
assert!(!is_plan_mode_request("Review chuỗi \"lên kế hoạch\""));
}

#[test]
fn multi_agent_intent_requires_an_explicit_user_request() {
assert!(is_explicit_multi_agent_request(
"Chia agent đọc file giúp tôi"
));
assert!(is_explicit_multi_agent_request(
"Chia agent: create delegated reviewer"
));
assert!(is_explicit_multi_agent_request(
"Thử chia agent đọc các file chưa commit"
));
assert!(is_explicit_multi_agent_request(
"Chia ra 3 agent để audit song song"
));
assert!(is_explicit_multi_agent_request(
"Use multiple agents to review this repo"
));
assert!(!is_explicit_multi_agent_request(
"Rà soát toàn bộ source code, sub agent, để tìm lỗi"
));
assert!(!is_explicit_multi_agent_request(
"Kiểm tra logic chia agent hiện tại"
));
assert!(!is_explicit_multi_agent_request(
"Kiểm tra logic dùng nhiều agent hiện tại"
));
assert!(!is_explicit_multi_agent_request(
"Không chia agent, làm trong cuộc trò chuyện hiện tại"
));
assert!(!is_explicit_multi_agent_request(
"Review chuỗi `chia agent` trong source"
));
assert!(!is_explicit_multi_agent_request(
"Đừng thử chia agent; làm trong cuộc trò chuyện hiện tại"
));
assert!(!is_explicit_multi_agent_request(
"Review nhãn \"thử chia agent\" trong UI"
));
}

#[test]
fn intent_hint_never_grants_execution_permission() {
let review = intent_hint("Chỉ review, đừng sửa");
Expand Down
Loading
Loading