use crate::cm_tools::tool_result::{
normalize_tool_message_content, tool_message_payload_for_inner_parse,
};
use crate::cm_types::Message;
use serde_json::Value;
use crate::cm_agent::log_preview::preview_chars;
use crate::cm_agent::plan_artifact;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanRewriteExhaustedReason {
PlanMissing,
PlanLayerCountMismatch,
PlanWorkflowNodeIdsInvalid,
PlanWorkflowNodeCoverageIncomplete,
PlanValidateOnlyNodeBindingMismatch,
PlanSemanticInconsistent,
ExhaustedOther,
}
impl PlanRewriteExhaustedReason {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::PlanMissing => "plan_missing",
Self::PlanLayerCountMismatch => "plan_layer_count_mismatch",
Self::PlanWorkflowNodeIdsInvalid => "plan_workflow_node_ids_invalid",
Self::PlanWorkflowNodeCoverageIncomplete => "plan_workflow_node_coverage_incomplete",
Self::PlanValidateOnlyNodeBindingMismatch => "plan_validate_only_node_binding_mismatch",
Self::PlanSemanticInconsistent => "plan_semantic_inconsistent",
Self::ExhaustedOther => "plan_rewrite_exhausted_other",
}
}
}
pub fn user_text_semantic_mismatch_with_feedback(
violation_codes: &[String],
rationale: Option<&str>,
) -> String {
let codes_json: Vec<&str> = if violation_codes.is_empty() {
vec!["semantic_mismatch_unspecified"]
} else {
violation_codes.iter().map(String::as_str).collect()
};
let rationale_json = rationale
.map(|s| Value::String(s.to_string()))
.unwrap_or(Value::Null);
let machine = serde_json::json!({
"kind": "crabmate_plan_semantic_feedback",
"version": 1,
"violation_codes": codes_json,
"rationale": rationale_json,
});
let machine_line = serde_json::to_string(&machine).unwrap_or_else(|_| {
"{\"kind\":\"crabmate_plan_semantic_feedback\",\"version\":1,\"violation_codes\":[\"semantic_mismatch_unspecified\"],\"rationale\":null}".to_string()
});
format!(
"侧向校验认为你的 **agent_reply_plan** 与**最近工具执行结果**存在明显矛盾。请根据下方 **violation_codes**(及可选 **rationale**)与**真实工具结果**修正规划 JSON 与说明文字。\n\n```json\n{}\n```\n\n{}",
machine_line,
plan_rewrite_user_text_base()
)
}
pub fn plan_rewrite_user_text_base() -> String {
format!(
"你的最终回答缺少或未通过**结构化规划**校验。请在 content 中加入一段 Markdown 代码围栏(语言标记为 json),其内为合法 JSON,且必须满足:\n{}\n\n示例:\n```json\n{}\n```\n\n请直接重写本轮最终回答(可有其它说明文字,但须包含上述 JSON 围栏)。",
plan_artifact::PLAN_V1_REWRITE_BRIEF_RULES,
plan_artifact::PLAN_V1_REWRITE_EXAMPLE_JSON
)
}
pub fn plan_rewrite_user_text_with_issue(issue: &str) -> String {
let issue = issue.trim();
if issue.is_empty() {
return plan_rewrite_user_text_base();
}
format!(
"**校验反馈**(请针对修正,勿忽略):`{issue}`\n\n{}",
plan_rewrite_user_text_base()
)
}
fn exhausted_layer_count_mismatch(
plan: &plan_artifact::AgentReplyPlanV1,
layer_need: Option<usize>,
apply_layer_semantics: bool,
) -> bool {
matches!(
layer_need,
Some(n) if n > 0 && apply_layer_semantics && plan.steps.len() < n
)
}
fn exhausted_workflow_subset_mismatch(
plan: &plan_artifact::AgentReplyPlanV1,
wf_ids: Option<&Vec<String>>,
) -> bool {
wf_ids
.map(|ids| plan_artifact::validate_plan_workflow_node_ids_subset(plan, ids).is_err())
.unwrap_or(false)
}
fn exhausted_workflow_coverage_mismatch(
plan: &plan_artifact::AgentReplyPlanV1,
wf_ids: Option<&Vec<String>>,
strict_workflow_node_coverage: bool,
) -> bool {
strict_workflow_node_coverage
&& wf_ids
.map(|ids| {
plan_artifact::validate_plan_covers_all_workflow_node_ids(plan, ids).is_err()
})
.unwrap_or(false)
}
fn exhausted_validate_only_binding_mismatch(
plan: &plan_artifact::AgentReplyPlanV1,
messages: &[Message],
apply_layer_semantics: bool,
) -> bool {
apply_layer_semantics
&& last_workflow_validate_binding_plan_node_ids(messages)
.filter(|ids| !ids.is_empty())
.map(|ids| {
plan_artifact::validate_plan_binds_workflow_validate_nodes(plan, ids.as_slice())
.is_err()
})
.unwrap_or(false)
}
pub fn classify_exhausted_reason(
msg: &Message,
messages: &[Message],
layer_need: Option<usize>,
apply_layer_semantics: bool,
strict_workflow_node_coverage: bool,
) -> PlanRewriteExhaustedReason {
let content = crate::cm_types::message_content_as_str(&msg.content).unwrap_or("");
let validate_only_binding_ids = if apply_layer_semantics {
last_workflow_validate_binding_plan_node_ids(messages)
} else {
None
};
let Ok(plan) = plan_artifact::parse_agent_reply_plan_v1_with_validate_only_binding_ids(
content,
validate_only_binding_ids.as_deref(),
) else {
return PlanRewriteExhaustedReason::PlanMissing;
};
if exhausted_layer_count_mismatch(&plan, layer_need, apply_layer_semantics) {
return PlanRewriteExhaustedReason::PlanLayerCountMismatch;
}
let wf_ids = last_workflow_tool_node_ids(messages);
if exhausted_workflow_subset_mismatch(&plan, wf_ids.as_ref()) {
return PlanRewriteExhaustedReason::PlanWorkflowNodeIdsInvalid;
}
if exhausted_workflow_coverage_mismatch(&plan, wf_ids.as_ref(), strict_workflow_node_coverage) {
return PlanRewriteExhaustedReason::PlanWorkflowNodeCoverageIncomplete;
}
if exhausted_validate_only_binding_mismatch(&plan, messages, apply_layer_semantics) {
return PlanRewriteExhaustedReason::PlanValidateOnlyNodeBindingMismatch;
}
PlanRewriteExhaustedReason::ExhaustedOther
}
fn workflow_execute_report_node_ids(v: &Value) -> Option<Vec<String>> {
let rt = v.get("report_type").and_then(|x| x.as_str());
if !matches!(
rt,
Some("workflow_validate_result") | Some("workflow_execute_result")
) {
return None;
}
let nodes = v.get("nodes").and_then(|x| x.as_array())?;
let mut ids = Vec::new();
for n in nodes {
let id = n.get("id").and_then(|x| x.as_str())?;
ids.push(id.to_string());
}
if ids.is_empty() {
None
} else {
Some(ids)
}
}
fn try_node_ids_from_workflow_execute_tool_message(
messages: &[Message],
tool_idx: usize,
) -> Option<Vec<String>> {
let m = &messages[tool_idx];
let tid = m.tool_call_id.as_deref()?;
let aidx = assistant_index_for_tool_call(messages, tool_idx, tid)?;
let assistant = &messages[aidx];
let name = assistant
.tool_calls
.as_ref()?
.iter()
.find(|c| c.id == tid)
.map(|c| c.function.name.as_str())?;
if name != "workflow_execute" {
return None;
}
let body = crate::cm_types::message_content_as_str(&m.content)?;
let payload = tool_message_payload_for_inner_parse(body);
let v: Value = serde_json::from_str(payload.as_ref()).ok()?;
workflow_execute_report_node_ids(&v)
}
pub fn last_workflow_tool_node_ids(messages: &[Message]) -> Option<Vec<String>> {
for i in (0..messages.len()).rev() {
if messages[i].role != "tool" {
continue;
}
if let Some(ids) = try_node_ids_from_workflow_execute_tool_message(messages, i) {
return Some(ids);
}
}
None
}
fn workflow_execute_tool_payload_json(messages: &[Message], i: usize) -> Option<Value> {
let m = &messages[i];
if m.role != "tool" {
return None;
}
let tid = m.tool_call_id.as_deref()?;
let aidx = assistant_index_for_tool_call(messages, i, tid)?;
let name = messages[aidx]
.tool_calls
.as_ref()?
.iter()
.find(|c| c.id == tid)
.map(|c| c.function.name.as_str())?;
if name != "workflow_execute" {
return None;
}
let body = crate::cm_types::message_content_as_str(&m.content)?;
let payload = tool_message_payload_for_inner_parse(body);
serde_json::from_str(payload.as_ref()).ok()
}
fn layer_count_from_validate_result(v: &Value) -> Option<usize> {
if v.get("report_type").and_then(|x| x.as_str()) != Some("workflow_validate_result") {
return None;
}
v.get("spec")
.and_then(|s| s.get("layer_count"))
.and_then(|x| x.as_u64())
.map(|n| n as usize)
}
pub fn last_workflow_validate_layer_count(messages: &[Message]) -> Option<usize> {
(0..messages.len())
.rev()
.find_map(|i| layer_count_from_validate_result(&workflow_execute_tool_payload_json(messages, i)?))
}
pub fn last_workflow_validate_binding_plan_node_ids(messages: &[Message]) -> Option<Vec<String>> {
for i in (0..messages.len()).rev() {
let m = &messages[i];
if m.role != "tool" {
continue;
}
let Some(tid) = m.tool_call_id.as_deref() else {
continue;
};
let Some(aidx) = assistant_index_for_tool_call(messages, i, tid) else {
continue;
};
let assistant = &messages[aidx];
let Some(name) = assistant
.tool_calls
.as_ref()
.and_then(|tc| tc.iter().find(|c| c.id == tid))
.map(|c| c.function.name.as_str())
else {
continue;
};
if name != "workflow_execute" {
continue;
}
let Some(body) = crate::cm_types::message_content_as_str(&m.content) else {
continue;
};
let payload = tool_message_payload_for_inner_parse(body);
let Ok(v) = serde_json::from_str::<Value>(payload.as_ref()) else {
continue;
};
if v.get("report_type").and_then(|x| x.as_str()) != Some("workflow_validate_result") {
continue;
}
let Some(nodes) = v.get("nodes").and_then(|x| x.as_array()) else {
continue;
};
let mut ids = Vec::new();
for n in nodes {
let Some(id) = n.get("id").and_then(|x| x.as_str()) else {
continue;
};
ids.push(id.to_string());
}
if ids.is_empty() {
continue;
}
return Some(ids);
}
None
}
pub fn validate_only_plan_binding_rewrite_suffix(validate_only_node_ids: &[String]) -> String {
if validate_only_node_ids.is_empty() {
return String::new();
}
let n = validate_only_node_ids.len();
format!(
"\n\n**validate_only 绑定点(必守)**:最近一次 `workflow_validate_only` 的 `nodes` 共 **{n}** 个(DAG 顺序可异于下列列表,但绑定须一致)。你的 `agent_reply_plan` 须满足:\n\
1. `steps.len()` **等于** **{n}**(与 `nodes` 个数相同)。\n\
2. **每一步**均须设置 **`workflow_node_id`**(不得省略)。\n\
3. 全部 `workflow_node_id` 构成的**多重集合**须与下列节点 id **完全一致**(含重复次数;`steps` 顺序可与下列不同):`{}`。",
validate_only_node_ids.join(", ")
)
}
const SEMANTIC_CHECK_HIGH_RISK_TOOLS: &[&str] = &[
"run_command",
"run_executable",
"workflow_execute",
"create_file",
"edit_file",
"apply_patch",
"http_request",
];
fn tool_name_is_high_risk(name: &str) -> bool {
SEMANTIC_CHECK_HIGH_RISK_TOOLS.contains(&name) || name.starts_with("mcp__")
}
fn semantic_check_tool_name_body_for_message(
messages: &[Message],
tool_idx: usize,
) -> Option<(&str, &str)> {
let m = &messages[tool_idx];
let tid = m.tool_call_id.as_deref()?;
let aidx = assistant_index_for_tool_call(messages, tool_idx, tid)?;
let assistant = &messages[aidx];
let tc = assistant
.tool_calls
.as_ref()?
.iter()
.find(|c| c.id == tid)?;
let name = tc.function.name.as_str();
let body = crate::cm_types::message_content_as_str(&m.content).unwrap_or("");
Some((name, body))
}
fn semantic_check_include_tool_line(
is_readonly_tool: &dyn Fn(&str) -> bool,
name: &str,
non_ro_used: &mut usize,
max_non_readonly_tools: usize,
) -> bool {
let is_ro = is_readonly_tool(name);
let is_risky = tool_name_is_high_risk(name);
if is_ro {
return true;
}
if *non_ro_used >= max_non_readonly_tools && !is_risky {
return false;
}
*non_ro_used += 1;
true
}
fn semantic_check_format_tool_summary_line(name: &str, body: &str, max_chars_per: usize) -> String {
let mut line = if let Some(env) = normalize_tool_message_content(body) {
let out_head = preview_chars(env.output.as_str(), 320);
format!(
"- {} ok={} summary={} out_preview={}",
env.name,
env.ok,
crate::cm_tools::redact::single_line_preview(env.summary.as_str(), 160),
out_head
)
} else {
format!(
"- {} legacy_preview={}",
name,
preview_chars(body, max_chars_per)
)
};
if line.chars().count() > max_chars_per {
line = preview_chars(&line, max_chars_per);
}
line
}
pub fn summarize_messages_for_final_plan_semantic_check(
messages: &[Message],
is_readonly_tool: &dyn Fn(&str) -> bool,
workspace_is_set: bool,
max_non_readonly_tools: usize,
) -> Option<String> {
let mut lines: Vec<String> = Vec::new();
let mut non_ro_used = 0usize;
const MAX_LINES: usize = 12;
const MAX_CHARS_PER: usize = 900;
for i in (0..messages.len()).rev() {
if lines.len() >= MAX_LINES {
break;
}
if messages[i].role != "tool" {
continue;
}
let (name, body) = semantic_check_tool_name_body_for_message(messages, i)?;
if !semantic_check_include_tool_line(
is_readonly_tool,
name,
&mut non_ro_used,
max_non_readonly_tools,
) {
continue;
}
lines.push(semantic_check_format_tool_summary_line(
name,
body,
MAX_CHARS_PER,
));
}
if lines.is_empty() {
return None;
}
lines.reverse();
let header = if workspace_is_set {
"以下为逆序收集的最近工具结果摘要(较新在后);请判断与 agent_reply_plan 是否矛盾。"
} else {
"以下为逆序收集的最近工具结果摘要(工作区未设置,可能较不完整);请判断与 agent_reply_plan 是否矛盾。"
};
Some(format!("{}\n{}", header, lines.join("\n")))
}
fn assistant_index_for_tool_call(
messages: &[Message],
tool_idx: usize,
tool_call_id: &str,
) -> Option<usize> {
for j in (0..tool_idx).rev() {
if messages[j].role != "assistant" {
continue;
}
let calls = messages[j].tool_calls.as_ref()?;
if calls.iter().any(|c| c.id == tool_call_id) {
return Some(j);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cm_agent::plan_artifact::{
PLAN_V1_REWRITE_EXAMPLE_JSON, PLAN_V1_SCHEMA_RULES, parse_agent_reply_plan_v1,
};
#[test]
fn rewrite_user_base_is_much_shorter_than_full_schema_rules() {
let base = plan_rewrite_user_text_base();
let rules_len = PLAN_V1_SCHEMA_RULES.chars().count();
let base_len = base.chars().count();
assert!(
base_len * 2 < rules_len,
"rewrite user should omit full SCHEMA_RULES: base_len={base_len} rules_len={rules_len}"
);
assert!(
!base.contains("expect_json_path_equals"),
"rewrite user must not embed full acceptance schema rules"
);
assert!(base.contains(PLAN_V1_REWRITE_EXAMPLE_JSON));
assert!(base.contains("agent_reply_plan"));
}
#[test]
fn rewrite_example_json_parses_as_v1() {
parse_agent_reply_plan_v1(PLAN_V1_REWRITE_EXAMPLE_JSON)
.expect("PLAN_V1_REWRITE_EXAMPLE_JSON must parse");
}
#[test]
fn rewrite_user_with_issue_prefixes_feedback() {
let text = plan_rewrite_user_text_with_issue("not_found");
assert!(text.contains("校验反馈"));
assert!(text.contains("not_found"));
assert!(text.contains("agent_reply_plan"));
assert!(!text.contains("expect_json_path_equals"));
}
}