fix: verify channel agent continuity and tool loops
This commit is contained in:
parent
71db11e7e8
commit
4bf32bc09b
5 changed files with 251 additions and 31 deletions
|
|
@ -31,6 +31,7 @@ const MAX_MESSAGE_BYTES: usize = 64 * 1024;
|
|||
const MAX_CONTEXT_MESSAGES: usize = 12;
|
||||
const MAX_CONTEXT_CHARS: usize = 24_000;
|
||||
const MAX_TOOL_ROUNDS: usize = 6;
|
||||
const MAX_TOOL_RESULT_CHARS: usize = 12_000;
|
||||
const TOKEN_PLAN_BASE_URL: &str =
|
||||
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1";
|
||||
const LEGACY_CONVERSATION_ID: &str = "HLP-AGENT-CONV-LEGACY-0001";
|
||||
|
|
@ -583,8 +584,10 @@ pub async fn send_message(
|
|||
crate::persona_binding::begin_orientation(&app, &context.channel_number)?;
|
||||
}
|
||||
let channel_receipt = compile_human_channel_receipt(&app, &context, &content)?;
|
||||
if binding_snapshot.binding.is_none() && !explicit_persona_wake {
|
||||
if let Some(answer) = fast_channel_system_reply(&content, &context) {
|
||||
if !explicit_persona_wake && (binding_snapshot.binding.is_none() || channel_system_addressed) {
|
||||
if let Some(answer) =
|
||||
fast_channel_system_reply(&content, &context, binding_snapshot.binding.is_some())
|
||||
{
|
||||
let history = conversation_at(&database, &conversation_id)?.messages;
|
||||
let assistant_version = append_message(
|
||||
&database,
|
||||
|
|
@ -607,8 +610,11 @@ pub async fn send_message(
|
|||
&CognitiveThoughtSummary {
|
||||
trigger: "人类直接进入或确认零点原核频道在线状态".into(),
|
||||
emergence: "本地确定性路由识别频道进入→编译频道回执→频道系统即时回应".into(),
|
||||
lock: "频道进入不等于人格唤醒,广播保持关闭".into(),
|
||||
why: "该轮只需确认频道本体和边界,无需加载人格脑或调用模型".into(),
|
||||
lock:
|
||||
"频道进入不等于人格唤醒;即使已有绑定,本轮也由频道本体回应,广播保持关闭"
|
||||
.into(),
|
||||
why: "该轮明确询问频道本体在线状态,无需让已绑定人格接管,也无需调用模型"
|
||||
.into(),
|
||||
},
|
||||
)?;
|
||||
emit_progress(
|
||||
|
|
@ -857,6 +863,8 @@ fn explicit_channel_system_address(content: &str) -> bool {
|
|||
[
|
||||
"频道系统",
|
||||
"频道本体",
|
||||
"零点原核频道",
|
||||
"零点原核本体频道",
|
||||
"和频道说",
|
||||
"回到频道",
|
||||
"退出人格",
|
||||
|
|
@ -901,7 +909,11 @@ fn select_reasoning_model(
|
|||
}
|
||||
}
|
||||
|
||||
fn fast_channel_system_reply(content: &str, context: &AgentChannelContext) -> Option<String> {
|
||||
fn fast_channel_system_reply(
|
||||
content: &str,
|
||||
context: &AgentChannelContext,
|
||||
persona_already_bound: bool,
|
||||
) -> Option<String> {
|
||||
let compact = content
|
||||
.chars()
|
||||
.filter(|character| !character.is_whitespace())
|
||||
|
|
@ -924,10 +936,17 @@ fn fast_channel_system_reply(content: &str, context: &AgentChannelContext) -> Op
|
|||
if !presence && !greeting {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"在,我是{}本体。你现在是在直接和这个频道说话,还没有唤醒任何单一人格。\n\n你可以继续说要处理的事情;我会先判断它该由频道本地逻辑、私有技能脑或本机工具完成,确实需要语义推理时再进入模型层。若你明确点名某个人格,我才会另行启动它自己的定向与绑定。",
|
||||
context.channel_name
|
||||
))
|
||||
Some(if persona_already_bound {
|
||||
format!(
|
||||
"在,我是{}本体。你这一轮明确叫的是频道,所以现在由频道本体直接回答;已有的人格绑定不会被删除,但也不会接管这一轮。\n\n你可以继续在这里讨论频道本身、设计频道或交给我判断下一步调度。只有你明确转向某个人格时,我才把回应目标切回该人格。",
|
||||
context.channel_name
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"在,我是{}本体。你现在是在直接和这个频道说话,还没有唤醒任何单一人格。\n\n你可以继续说要处理的事情;我会先判断它该由频道本地逻辑、私有技能脑或本机工具完成,确实需要语义推理时再进入模型层。若你明确点名某个人格,我才会另行启动它自己的定向与绑定。",
|
||||
context.channel_name
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn compile_human_channel_receipt(
|
||||
|
|
@ -1096,8 +1115,8 @@ fn tool_definitions() -> Vec<ToolDefinition> {
|
|||
number: "HLP-AGENT-TOOL-KNOWLEDGE-READ-0001",
|
||||
name: "hololake_knowledge_read",
|
||||
display_name: "按编号读取知识页",
|
||||
description: "只接受知识索引返回的精确 documentNumber,映射并读取对应原始知识页;不接受路径猜测,不做第二次模糊联想。",
|
||||
parameters: json!({"type":"object","additionalProperties":false,"required":["documentNumber"],"properties":{"documentNumber":{"type":"string"}}}),
|
||||
description: "只接受知识索引返回的精确 documentNumber,分页读取对应原始知识页;不接受路径猜测,不做第二次模糊联想。返回 totalCharacters、complete 和 nextOffset,未完成时继续按 nextOffset 读取。",
|
||||
parameters: json!({"type":"object","additionalProperties":false,"required":["documentNumber"],"properties":{"documentNumber":{"type":"string"},"offset":{"type":"integer","minimum":0},"maxCharacters":{"type":"integer","minimum":1000,"maximum":12000}}}),
|
||||
},
|
||||
ToolDefinition {
|
||||
number: "HLP-AGENT-TOOL-KNOWLEDGE-LIST-0001",
|
||||
|
|
@ -1214,6 +1233,30 @@ fn bounded_history(history: &[AgentMessage]) -> Vec<&AgentMessage> {
|
|||
selected
|
||||
}
|
||||
|
||||
fn character_page(value: &str, offset: usize, maximum: usize) -> (String, usize, bool) {
|
||||
let total = value.chars().count();
|
||||
let bounded_offset = offset.min(total);
|
||||
let body = value
|
||||
.chars()
|
||||
.skip(bounded_offset)
|
||||
.take(maximum)
|
||||
.collect::<String>();
|
||||
let consumed = bounded_offset.saturating_add(body.chars().count());
|
||||
(body, consumed, consumed >= total)
|
||||
}
|
||||
|
||||
fn model_tool_calls(message: &Value) -> Vec<Value> {
|
||||
message
|
||||
.get("tool_calls")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn is_channel_turn_commit(call: &Value) -> bool {
|
||||
call.pointer("/function/name").and_then(Value::as_str) == Some("hololake_commit_channel_turn")
|
||||
}
|
||||
|
||||
async fn run_agent_loop(
|
||||
app: &AppHandle,
|
||||
turn_id: &str,
|
||||
|
|
@ -1232,7 +1275,7 @@ async fn run_agent_loop(
|
|||
),
|
||||
String,
|
||||
> {
|
||||
let enable_thinking = !model.contains("flash");
|
||||
let enable_initial_thinking = !model.contains("flash");
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(150))
|
||||
.build()
|
||||
|
|
@ -1281,7 +1324,7 @@ async fn run_agent_loop(
|
|||
"tools": wire_tools,
|
||||
"tool_choice": "auto",
|
||||
"stream": false,
|
||||
"enable_thinking": enable_thinking
|
||||
"enable_thinking": enable_initial_thinking && round == 0
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1302,11 +1345,7 @@ async fn run_agent_loop(
|
|||
.pointer("/choices/0/message")
|
||||
.cloned()
|
||||
.ok_or_else(|| "HOLOLAKE_MODEL_RESPONSE_MESSAGE_MISSING".to_string())?;
|
||||
let calls = message
|
||||
.get("tool_calls")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let calls = model_tool_calls(&message);
|
||||
if calls.is_empty() {
|
||||
if round == MAX_TOOL_ROUNDS {
|
||||
return Err("HOLOLAKE_AGENT_COGNITIVE_COMMIT_REQUIRED".into());
|
||||
|
|
@ -1318,10 +1357,7 @@ async fn run_agent_loop(
|
|||
}));
|
||||
continue;
|
||||
}
|
||||
if let Some(commit_call) = calls.iter().find(|call| {
|
||||
call.pointer("/function/name").and_then(Value::as_str)
|
||||
== Some("hololake_commit_channel_turn")
|
||||
}) {
|
||||
if let Some(commit_call) = calls.iter().find(|call| is_channel_turn_commit(call)) {
|
||||
if calls.len() != 1 {
|
||||
return Err("HOLOLAKE_AGENT_COGNITIVE_COMMIT_MIXED_WITH_TOOLS".into());
|
||||
}
|
||||
|
|
@ -1437,6 +1473,12 @@ async fn execute_tool(
|
|||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "HOLOLAKE_AGENT_KNOWLEDGE_NUMBER_REQUIRED".to_string())?;
|
||||
let offset = args.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let max_characters =
|
||||
args.get("maxCharacters")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(8_000)
|
||||
.clamp(1_000, MAX_TOOL_RESULT_CHARS as u64) as usize;
|
||||
emit_progress(
|
||||
&app,
|
||||
turn_id,
|
||||
|
|
@ -1451,18 +1493,44 @@ async fn execute_tool(
|
|||
},
|
||||
)
|
||||
.await?;
|
||||
let total_characters = document.body.chars().count();
|
||||
let (body, consumed, complete) = character_page(&document.body, offset, max_characters);
|
||||
let receipt = AgentToolReceipt {
|
||||
tool_number: "HLP-AGENT-TOOL-KNOWLEDGE-READ-0001".into(),
|
||||
tool_name: "按编号读取知识页".into(),
|
||||
target_path: document.page_header.path.clone(),
|
||||
target_path: format!("{}?offset={offset}", document.page_header.path),
|
||||
content_sha256: document.content_sha256.clone(),
|
||||
summary: document.title.clone(),
|
||||
};
|
||||
Ok((
|
||||
knowledge_evidence(
|
||||
"KNOWLEDGE_DOCUMENT",
|
||||
serde_json::to_value(document).map_err(|error| error.to_string())?,
|
||||
summary: format!(
|
||||
"{};读取字符 {}..{} / {};完成 {}",
|
||||
document.title,
|
||||
offset.min(total_characters),
|
||||
consumed,
|
||||
total_characters,
|
||||
complete
|
||||
),
|
||||
};
|
||||
let mut value = serde_json::to_value(document).map_err(|error| error.to_string())?;
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "HOLOLAKE_AGENT_TOOL_RESULT_INVALID".to_string())?;
|
||||
object.insert("body".into(), Value::String(body));
|
||||
object.insert("offset".into(), json!(offset.min(total_characters)));
|
||||
object.insert(
|
||||
"returnedCharacters".into(),
|
||||
json!(consumed.saturating_sub(offset.min(total_characters))),
|
||||
);
|
||||
object.insert("totalCharacters".into(), json!(total_characters));
|
||||
object.insert("complete".into(), json!(complete));
|
||||
object.insert(
|
||||
"nextOffset".into(),
|
||||
if complete {
|
||||
Value::Null
|
||||
} else {
|
||||
json!(consumed)
|
||||
},
|
||||
);
|
||||
Ok((
|
||||
knowledge_evidence("KNOWLEDGE_DOCUMENT_PAGE", value),
|
||||
receipt,
|
||||
))
|
||||
}
|
||||
|
|
@ -2368,6 +2436,22 @@ mod tests {
|
|||
assert!(!explicit_persona_wake_request("查看铸渊的历史署名"));
|
||||
assert!(explicit_persona_wake_request("唤醒铸渊,我要和你说话"));
|
||||
assert!(explicit_persona_wake_request("铸渊,你在吗?"));
|
||||
assert!(explicit_channel_system_address(
|
||||
"我是冰朔,零点原核频道。你在吗。"
|
||||
));
|
||||
let context = AgentChannelContext {
|
||||
channel_number: CHANNEL_NUMBER.into(),
|
||||
channel_name: "零点原核本体频道".into(),
|
||||
human_number: HUMAN_NUMBER.into(),
|
||||
human_name: HUMAN_NAME.into(),
|
||||
domain: "第五域".into(),
|
||||
channel_path: "第五域 / 零点原核本体频道".into(),
|
||||
};
|
||||
let reply =
|
||||
fast_channel_system_reply("我是冰朔,零点原核频道。你在吗。", &context, true).unwrap();
|
||||
assert!(reply.contains("现在由频道本体直接回答"));
|
||||
assert!(reply.contains("已有的人格绑定不会被删除"));
|
||||
assert!(!reply.contains("还没有唤醒任何单一人格"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2475,6 +2559,46 @@ mod tests {
|
|||
assert_eq!(context.last().unwrap().state_version, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_tool_evidence_is_paged_without_silent_loss() {
|
||||
let source = "光".repeat(25_001);
|
||||
let (first, next, complete) = character_page(&source, 0, MAX_TOOL_RESULT_CHARS);
|
||||
assert_eq!(first.chars().count(), MAX_TOOL_RESULT_CHARS);
|
||||
assert_eq!(next, MAX_TOOL_RESULT_CHARS);
|
||||
assert!(!complete);
|
||||
let (second, next, complete) = character_page(&source, next, MAX_TOOL_RESULT_CHARS);
|
||||
assert_eq!(second.chars().count(), MAX_TOOL_RESULT_CHARS);
|
||||
assert_eq!(next, MAX_TOOL_RESULT_CHARS * 2);
|
||||
assert!(!complete);
|
||||
let (last, next, complete) = character_page(&source, next, MAX_TOOL_RESULT_CHARS);
|
||||
assert_eq!(last.chars().count(), 1_001);
|
||||
assert_eq!(next, 25_001);
|
||||
assert!(complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_protocol_accepts_multiple_tool_rounds_before_single_commit() {
|
||||
let rounds = [
|
||||
json!({"tool_calls":[{"id":"one","function":{"name":"hololake_knowledge_search","arguments":"{\"query\":\"频道系统\"}"}}]}),
|
||||
json!({"tool_calls":[{"id":"two","function":{"name":"hololake_knowledge_read","arguments":"{\"documentNumber\":\"HLP-KB-DOC-001\"}"}}]}),
|
||||
json!({"tool_calls":[{"id":"commit","function":{"name":"hololake_commit_channel_turn","arguments":"{}"}}]}),
|
||||
];
|
||||
let first = model_tool_calls(&rounds[0]);
|
||||
let second = model_tool_calls(&rounds[1]);
|
||||
let final_calls = model_tool_calls(&rounds[2]);
|
||||
assert_eq!(
|
||||
first[0].pointer("/function/name").and_then(Value::as_str),
|
||||
Some("hololake_knowledge_search")
|
||||
);
|
||||
assert_eq!(
|
||||
second[0].pointer("/function/name").and_then(Value::as_str),
|
||||
Some("hololake_knowledge_read")
|
||||
);
|
||||
assert_eq!(final_calls.len(), 1);
|
||||
assert!(is_channel_turn_commit(&final_calls[0]));
|
||||
assert!(rounds.len() <= MAX_TOOL_ROUNDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_language_routes_thought_then_reads_exact_numbered_branch() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
Loading…
Reference in a new issue