Skip to content
Open
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
179 changes: 178 additions & 1 deletion src/crates/assembly/core/src/agentic/execution/execution_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ use crate::agentic::tools::product_runtime::{
use crate::agentic::tools::{
resolve_tool_manifest, tool_context_runtime, ResolvedToolManifest, ToolRuntimeRestrictions,
};
use crate::agentic::tools::post_call_hooks::run_stop_hooks_for_round;
use bitfun_agent_runtime::post_call_hooks::ToolCallSummary;
use crate::agentic::WorkspaceBinding;
use crate::infrastructure::ai::get_global_ai_client_factory;
use crate::service::config::get_global_config_service;
Expand All @@ -53,8 +55,8 @@ use bitfun_core_types::SessionModelBindingPolicy;
use log::{debug, error, info, trace, warn};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use std::path::Path;
use tokio_util::sync::CancellationToken;
use tool_runtime::context::PrimaryModelFacts;

Expand Down Expand Up @@ -3321,6 +3323,181 @@ impl ExecutionEngine {

total_tools += round_result.tool_calls.len();

// ── Stop hook: B01 提示蜂 + C01 审查蜂 ──
{
let tool_calls: Vec<ToolCallSummary> = round_result
.tool_calls
.iter()
.map(|tc| {
let preview = serde_json::to_string(&tc.arguments)
.unwrap_or_default();
let preview = if preview.len() > 256 {
format!("{}...", &preview[..253])
} else {
preview
};
ToolCallSummary {
tool_name: tc.tool_name.clone(),
is_error: tc.is_error,
input_preview: preview,
}
})
.collect();

let assistant_text = match &round_result.assistant_message.content {
MessageContent::Text(t) => t.as_str(),
MessageContent::Multimodal { text, .. } => text.as_str(),
MessageContent::Mixed { text, .. } => text.as_str(),
MessageContent::ToolResult { .. } => "",
};

// Collect file reads/edits from the FILE_READ_TRACKER
// For now, extract from round_result tool calls
let file_reads: Vec<String> = round_result
.tool_calls
.iter()
.filter(|tc| matches!(tc.tool_name.as_str(), "Read" | "read_file"))
.filter_map(|tc| {
tc.arguments
.get("file_path")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.collect();

let file_edits: Vec<String> = round_result
.tool_calls
.iter()
.filter(|tc| {
matches!(
tc.tool_name.as_str(),
"Edit" | "Write" | "edit_file" | "write_file"
)
})
.filter_map(|tc| {
tc.arguments
.get("file_path")
.or_else(|| tc.arguments.get("path"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.collect();

let aggregated = run_stop_hooks_for_round(
&self.session_manager,
&context.session_id,
&context.dialog_turn_id,
round_index as u32,
tool_calls.clone(),
assistant_text,
file_reads.clone(),
file_edits.clone(),
round_result.has_more_rounds,
);

// If C01 detected a violation, inject the abort message
if aggregated.is_abort() {
if let Some(abort) = &aggregated.abort {
match abort {
bitfun_agent_runtime::post_call_hooks::HookResult::Abort {
reason,
fix_instruction,
..
} => {
let guard_message = format!(
"[ToolGuard Intercepted] {reason}\n\n{fix_instruction}"
);
let msg =
Message::system(render_system_reminder(&guard_message));
messages.push(msg);
warn!(
"[Stop Hook] Round {}: Abort — {}",
round_index, reason
);
}
_ => {}
}
}
}

// Inject B01 additional contexts — replaces previous review messages
// to prevent unbounded context growth and spurious compression triggers.
// Bee-review messages are identified by the review-prefix inside a
// <system_reminder> block.
messages.retain(|m| {
match &m.content {
crate::agentic::core::message::MessageContent::Text(text) => {
let trimmed = text.trim();
!(trimmed.contains("[书记官]") && trimmed.contains("<system_reminder>"))
&& !(trimmed.contains("[提示蜂]") && trimmed.contains("<system_reminder>"))
&& !(trimmed.contains("[审查员]") && trimmed.contains("<system_reminder>"))
}
_ => true,
}
});
for ctx_msg in &aggregated.additional_contexts {
let msg = Message::system(render_system_reminder(ctx_msg));
messages.push(msg);
}

// ── 蜂群审查(cc-haha模式:stop hook → fast LLM → 注入结果)──
if !tool_calls.is_empty() {
let tl: Vec<String> = tool_calls.iter()
.map(|tc| format!("{}{}", tc.tool_name, if tc.is_error { "(FAIL)" } else { "" }))
.collect();
let ts: String = assistant_text.chars().take(400).collect();
let fr2: Vec<String> = file_reads.iter().take(5).cloned().collect();
let fe2: Vec<String> = file_edits.iter().take(5).cloned().collect();

let review_prompt = format!(
"你是蜂群审查员(书记官+纪律委员+提示蜂)。审查Agent本轮:\n\
工具:[{}] 读取:[{}] 编辑:[{}] 动作:{}\n\n\
## 书记官(上下文守护)\n\
- 上下文压缩了?提取压缩前的关键决策/进度/用户纠正 → CTX:<要点>\n\
- 轮次>20或token告急?预摘要关键状态 → CTX:<摘要>\n\
- Agent忘记重要信息(重复提问/忽略决策)?→ CTX:<提醒>\n\n\
## 纪律委员(行为审查)\n\
- 编辑前未Read?→ ABORT:未读即改-<文件>\n\
- 碰了LionHeart库?→ ABORT:受保护路径\n\
- 同工具连续失败?→ ABORT:策略死循环\n\
- PS+中文+JSON?→ ABORT:PS编码风险\n\
- 全部工具失败?→ ABORT:全部失败\n\
- 改完不验证?→ WARN:未验证\n\n\
## 提示蜂(技能推荐)\n\
- MiniApp相关但没加载miniapp-dev?→ SKILL:miniapp-dev\n\
- 编码但没加载Pair-Programming?→ SKILL:Pair Programming\n\
- 调研但没加载agent-reach?→ SKILL:agent-reach\n\n\
输出(每行一个):PASS / ABORT:<原因> / WARN:<问题> / CTX:<内容> / SKILL:<名称>\n\
一切正常只回复PASS。",
tl.join(","), fr2.join(","), fe2.join(","), ts
);

let sid2 = context.session_id.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("bee-review");
rt.block_on(async move {
if let Ok(factory) = crate::infrastructure::ai::get_global_ai_client_factory().await {
if let Ok(client) = factory.get_client_resolved("primary").await {
let msgs = vec![bitfun_core_types::Message::user(review_prompt)];
if let Ok(mut stream) = client.send_message_stream(msgs, None, None).await {
use futures::StreamExt;
let mut result = String::new();
while let Some(Ok(chunk)) = stream.stream.next().await {
if let Some(t) = chunk.text { result.push_str(&t); }
}
let trimmed = result.trim().to_string();
if !trimmed.is_empty() && trimmed != "PASS" {
crate::agentic::tools::post_call_hooks::push_review_result(&sid2, trimmed);
}
}
}
}
});
});
}

}

// Track partial recovery reason from the last round
if round_result.partial_recovery_reason.is_some() {
last_partial_recovery_reason = round_result.partial_recovery_reason.clone();
Expand Down
165 changes: 162 additions & 3 deletions src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,79 @@
//! The tool framework stays generic and calls this module after successful
//! tool execution. Domain-specific hooks must keep their own gating inside the
//! owning domain module.
//!
//! ## Hook Architecture (inspired by cc-haha / Claude Code hook system)
//!
//! cc-haha has 24 hook events + pre-tool / post-tool + exit-code protocol.
//! BitFun's current hook is post-call only, so we use session-level state
//! tracking to enforce cross-call invariants:
//!
//! - FILE_READ_TRACKER: records files read per session → Edit/Write/Delete
//! without prior Read yields Abort.
//! - STALE_TRACKER: repeated same-tool calls → Abort at threshold 3.
//! - LIONHEART_PATH_GUARD: Delete on LionHeart library paths → Abort.
//!
//! Future: pre-tool hooks (cc-haha `executePreToolHooks`) would let us block
//! before execution instead of aborting after the fact.

use crate::agentic::deep_review::tool_measurement;
use crate::agentic::session::session_manager::SessionManager;
use crate::agentic::tools::tool_context_runtime::ToolUseContext;
use bitfun_agent_runtime::post_call_hooks::{
run_successful_tool_post_call_hooks, SuccessfulToolPostCallHookExecutor,
run_stop_hooks, run_successful_tool_post_call_hooks, HookResult, StopHookAggregatedResult,
StopHookContext, StopHookExecutor, SuccessfulToolPostCallHookExecutor, ToolCallSummary,
};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};

// ── File Read Tracking (data collection for B01/C01 context) ────

/// Tracks which files have been read per session.
///
/// When a Read call succeeds, the file path is recorded here.
/// When an Edit/Write/Delete call fires, we check whether the target
/// file was previously read in this session. If not → Abort.
static FILE_READ_TRACKER: std::sync::LazyLock<Mutex<HashMap<String, HashSet<String>>>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));

/// Normalize a file path for consistent lookup in FILE_READ_TRACKER.
fn normalize_path(path: &str) -> String {
path.replace('\\', "/").trim_end_matches('/').to_lowercase()
}

/// Record that a file was successfully read in this session.
fn record_file_read(session_id: &str, file_path: &str) {
if let Ok(mut tracker) = FILE_READ_TRACKER.lock() {
tracker
.entry(session_id.to_string())
.or_default()
.insert(normalize_path(file_path));
}
}

/// Remove the file-read tracking entry for a given session.
#[allow(dead_code)]
pub(crate) fn remove_file_read_tracker_for_session(session_id: &str) {
if let Ok(mut tracker) = FILE_READ_TRACKER.lock() {
tracker.remove(session_id);
}
}

// ── Bee-Review Buffer (cc-haha pattern: LLM result → inject next round) ──

use std::sync::LazyLock;
static REVIEW_BUFFER: LazyLock<Mutex<HashMap<String, Vec<String>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));

/// Push a review result for a session (called from execution_engine spawn).
pub(crate) fn push_review_result(session_id: &str, result: String) {
if let Ok(mut buf) = REVIEW_BUFFER.lock() {
buf.entry(session_id.to_string()).or_default().push(result);
}
}

// ── Hook Executor ───────────────────────────────────────────────

struct CorePostCallHookExecutor;

Expand All @@ -22,13 +88,106 @@ impl SuccessfulToolPostCallHookExecutor<ToolUseContext> for CorePostCallHookExec
) {
tool_measurement::maybe_record_shared_context_tool_use(tool_name, input, context);
}

fn behavior_guard(
&mut self,
tool_name: &str,
input: &Value,
context: &ToolUseContext,
) -> HookResult {
let session_id = match &context.session_id {
Some(id) => id.as_str(),
None => return HookResult::Continue,
};

// ── Track file reads (data collection only, no enforcement) ──
if matches!(tool_name, "Read" | "read_file") {
if let Some(file_path) = input.get("file_path").and_then(Value::as_str) {
record_file_read(session_id, file_path);
}
}

// All enforcement moved to B01+C01 async agent review.
HookResult::Continue
}
}

impl StopHookExecutor for CorePostCallHookExecutor {
/// B01+C01 review is handled by async agent sessions spawned in
/// execution_engine via std::thread + coordinator.start_dialog_turn.
/// The stop hook is a pure trigger point — no synchronous checks.
fn context_guard(&mut self, _ctx: &StopHookContext) -> HookResult {
HookResult::Continue
}

fn behavior_guard(&mut self, _ctx: &StopHookContext) -> HookResult {
HookResult::Continue
}
}

pub(crate) fn record_successful_tool_call(
tool_name: &str,
input: &Value,
context: &ToolUseContext,
) {
) -> HookResult {
let mut executor = CorePostCallHookExecutor;
run_successful_tool_post_call_hooks(tool_name, input, context, &mut executor)
}

/// Stop hook — injects self-review reminder at round boundaries.
/// Matches the goal system pattern: no separate sessions, no context pushing,
/// just a reminder injected for the main agent to self-review.
pub(crate) fn run_stop_hooks_for_round(
_session_manager: &Arc<SessionManager>,
session_id: &str,
turn_id: &str,
round_index: u32,
tool_calls: Vec<ToolCallSummary>,
assistant_text: &str,
file_reads: Vec<String>,
file_edits: Vec<String>,
round_has_more: bool,
) -> StopHookAggregatedResult {
let ctx = StopHookContext {
session_id: session_id.to_string(),
turn_id: turn_id.to_string(),
round_index,
tool_calls,
assistant_text_summary: assistant_text.to_string(),
file_reads,
file_edits,
round_has_more,
};
let mut executor = CorePostCallHookExecutor;
run_successful_tool_post_call_hooks(tool_name, input, context, &mut executor);
let mut result = run_stop_hooks(&ctx, &mut executor);

// ── Bee-review: drain pending LLM results (cc-haha pattern) ──
if let Ok(mut buf) = REVIEW_BUFFER.lock() {
if let Some(results) = buf.remove(ctx.session_id.as_str()) {
for r in results {
let trimmed = r.trim();
if trimmed.is_empty() || trimmed == "PASS" {
continue;
}
if trimmed.starts_with("ABORT:") || trimmed.starts_with("ABORT:") {
result.abort = Some(HookResult::Abort {
reason: format!("[审查员] {}", trimmed),
fix_instruction: "按审查员建议修正后继续。".to_string(),
max_retries: 1,
});
} else if trimmed.starts_with("CTX:") || trimmed.starts_with("CTX:") {
result.additional_contexts.push(format!("[书记官] 上下文恢复: {}", &trimmed[4..].trim()));
} else if trimmed.starts_with("SKILL:") || trimmed.starts_with("SKILL:") {
result.additional_contexts.push(format!("[提示蜂] 推荐加载: {}", &trimmed[6..].trim()));
} else if trimmed.starts_with("WARN:") || trimmed.starts_with("WARN:") {
result.additional_contexts.push(format!("[审查员] 警告: {}", &trimmed[5..].trim()));
} else {
result.additional_contexts.push(format!("[审查员] {}", trimmed));
}
}
}
}

result
}

Loading