agent-loop: root turn driven by the library kernel (phase 2)
ChatSessionHandler now runs the root turn on the agent-loop kernel instead of run_agent_turn; sub-agents follow on the same kernel via DelegateTool. The old loop stays for resume/recovery until phase 3. agent-loop: - DelegateTool + AgentCatalog/AgentProfile (full toolset override, per-child selector/assembler, frame-scoped get), StaticCatalog, FilteredToolSet; sync flow with sticky child_token; batch via the generic fan-out - manager: start_loop skips the registry (children are not double-driving; they ride the parent's token tree); LoopParams gains selector/token overrides - store: get_frame, get_call, set_call_extras; HistoryStore result text aligned to raw-stored semantics (projection formats) - events: ApprovalRequired.request_id, AgentSpawned/Finished parent info; AskUserTool with_name + suggested_answers alias + Question.frame skald-core (loop_adapters + handler): - SkaldAssembler (byte-parity port of MessageBuilder's projection: scratchpad/summary/window, DTL Kimi/Anthropic injection, media, user coalescing, reasoning echo) + AgentSystemContext (prompt layers, substitutions, MCP list, shared folders, user profile) - SkaldAgentCatalog (build_sub_agent_config port), SkaldHumanChannel, scratchpad/todos tools, execute_task sync/async alias, LegacyInterfaceTool, PendingLiveInput - ApprovalGate: PendingWrite diffs via LoopEvent::Host (memory/disk routed like the fs-tools); SkaldWritePreviewHook for executed-write diffs; EventTranslator LoopEvent→ServerEvent (display meta, preview, FileChanged, AgentStart/Done, root-only Done/Truncated/Cancelled) - handle_message: builds TurnParams and drives the kernel; resume of pending tools runs first (results belong to the previous turn); ChatEvent publication stays handler-side; /stop cancels the live loop - ToolRegistry.get_tool/all_tools; def builders made pub(crate) Full workspace suite green (179 skald-core, 34 agent-loop, adapters incl.); two pre-existing doc-test failures fixed along the way.
This commit is contained in:
@@ -8,7 +8,7 @@ use super::{ChatSessionHandler, update_scratchpad_tool_def, write_todos_tool_def
|
||||
use super::interface_tools::{AgentRunConfig, InterfaceTool, ToolFuture};
|
||||
|
||||
/// Returns an `activate_tools` OpenAI tool definition.
|
||||
pub(super) fn activate_tools_tool_def() -> Value {
|
||||
pub(crate) fn activate_tools_tool_def() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
//! Kernel-driven root turn (phase 2, blueprint §14): `handle_message` builds
|
||||
//! the turn's `TurnParams` from its fields and drives the `agent-loop` kernel
|
||||
//! instead of `run_agent_turn`. The translator (`EventTranslator`) is the ONE
|
||||
//! bus subscriber producing the session's `ServerEvent`s.
|
||||
//!
|
||||
//! Sub-agents run on the same kernel via `DelegateTool` (sync); async
|
||||
//! `execute_task` still rides the legacy interface handler until phase 3.
|
||||
//! Recovery/resume stays on the old path until phase 3 as well.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_loop::activation::ActivateToolsTool;
|
||||
use agent_loop::delegate::DelegateTool;
|
||||
use agent_loop::ids::ConversationId;
|
||||
use agent_loop::manager::{LiveInput, LoopManager, TurnMeta, TurnParams};
|
||||
use agent_loop::model::{ModelHint, ModelSelector};
|
||||
use agent_loop::store::{HistoryStore, NewMessage};
|
||||
use agent_loop::tool::{Extensions, Tool as LoopTool, ToolSet};
|
||||
use core_api::interface_tool::InterfaceTool;
|
||||
use core_api::message_meta::MessageMetadata;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
|
||||
use crate::chat_event_bus::ToolCallEvent;
|
||||
use crate::events::ServerEvent;
|
||||
use crate::loop_adapters::activation::{SkaldActivationSource, SkaldToolActivator};
|
||||
use crate::loop_adapters::assembler::SkaldAssembler;
|
||||
use crate::loop_adapters::builtins::{
|
||||
ExecuteTaskAliasTool, LegacyInterfaceTool, SkaldAskUserTool, SkaldHumanChannel,
|
||||
UpdateScratchpadTool, WriteTodosTool,
|
||||
};
|
||||
use crate::loop_adapters::catalog::SkaldAgentCatalog;
|
||||
use crate::loop_adapters::gate::ApprovalGate;
|
||||
use crate::loop_adapters::history::SqliteHistory;
|
||||
use crate::loop_adapters::hooks::SkaldWritePreviewHook;
|
||||
use crate::loop_adapters::live_input::PendingLiveInput;
|
||||
use crate::loop_adapters::preview::PreviewContext;
|
||||
use crate::loop_adapters::selector::SkaldSelector;
|
||||
use crate::loop_adapters::system::AgentSystemContext;
|
||||
use crate::loop_adapters::toolset::{CallerUserId, SkaldToolSet};
|
||||
use crate::loop_adapters::translate::EventTranslator;
|
||||
use crate::tools::tool_names as tn;
|
||||
|
||||
use super::interface_tools::AgentRunConfig;
|
||||
use super::{ChatSessionHandler, MAX_AGENT_DEPTH, PendingUserInput, TurnOutcome};
|
||||
|
||||
/// Special-cased names handled natively (never legacy-wrapped).
|
||||
const NATIVE_NAMES: &[&str] = &[tn::ACTIVATE_TOOLS, tn::EXECUTE_TASK];
|
||||
|
||||
impl ChatSessionHandler {
|
||||
/// Runs the root turn on the `agent-loop` kernel. Same observable contract
|
||||
/// as `run_agent_turn` on the root: events over `tx`, `TurnOutcome` back.
|
||||
pub(super) async fn run_kernel_turn(
|
||||
&self,
|
||||
stack_id: i64,
|
||||
config: &AgentRunConfig,
|
||||
user_content: &str,
|
||||
is_synthetic: bool,
|
||||
metadata: Option<&MessageMetadata>,
|
||||
pending_input: Option<&Arc<dyn PendingUserInput>>,
|
||||
tx: &mpsc::Sender<ServerEvent>,
|
||||
) -> anyhow::Result<TurnOutcome> {
|
||||
let pool = self.db.clone();
|
||||
let shared_pool = self.shared_pool.clone();
|
||||
let conv = ConversationId::new(format!("session:{}", self.session_id));
|
||||
|
||||
// ── Store ──
|
||||
let store = Arc::new(SqliteHistory::new(pool.clone()));
|
||||
|
||||
// ── Selector (root strength from the agent meta, D14) ──
|
||||
let strength = crate::agents::load_meta(&config.agent_id)
|
||||
.ok()
|
||||
.and_then(|m| m.strength);
|
||||
let selector: Arc<dyn ModelSelector> =
|
||||
Arc::new(SkaldSelector::new(self.llm_manager.clone(), strength));
|
||||
|
||||
// ── Gate ──
|
||||
let group_id = self.tool_group_id().await;
|
||||
let gate = ApprovalGate::new(
|
||||
self.approval.clone(),
|
||||
store.clone(),
|
||||
self.tools.clone(),
|
||||
self.session_id,
|
||||
&self.source,
|
||||
group_id,
|
||||
self.run_context.clone(),
|
||||
self.pre_approved.clone(),
|
||||
self.auto_deny_approvals.clone(),
|
||||
self.context_label.clone(),
|
||||
pool.clone(),
|
||||
shared_pool.clone(),
|
||||
Some(self.fs.clone()),
|
||||
);
|
||||
|
||||
// ── Hooks ──
|
||||
let preview_hook = Arc::new(SkaldWritePreviewHook::new(PreviewContext {
|
||||
pool: pool.clone(),
|
||||
shared_pool: shared_pool.clone(),
|
||||
fs: Some(self.fs.clone()),
|
||||
}));
|
||||
|
||||
// ── Manager ──
|
||||
let manager = Arc::new(
|
||||
LoopManager::builder()
|
||||
.models(selector)
|
||||
.store(store.clone())
|
||||
.gate_arc(Arc::new(gate))
|
||||
.hook(preview_hook)
|
||||
.max_rounds(self.max_tool_rounds)
|
||||
.max_parallel_calls(self.max_parallel_subagents)
|
||||
.build()?,
|
||||
);
|
||||
|
||||
// ── Catalog + delegate ──
|
||||
let config_defs = Arc::new(config.config_tool_defs.clone());
|
||||
let catalog = Arc::new(SkaldAgentCatalog::new(
|
||||
pool.clone(),
|
||||
shared_pool.clone(),
|
||||
self.user_id.clone(),
|
||||
self.session_id,
|
||||
self.source.clone(),
|
||||
self.is_interactive,
|
||||
self.context_label.clone(),
|
||||
self.llm_manager.clone(),
|
||||
self.approval.clone(),
|
||||
self.clarification.clone(),
|
||||
self.mcp.clone(),
|
||||
self.tools.clone(),
|
||||
config.base_tool_defs.clone(),
|
||||
config_defs.clone(),
|
||||
config.memory_tools.clone(),
|
||||
config.image_tools.clone(),
|
||||
config.root_only_tool_names.clone(),
|
||||
self.datetime_config.clone(),
|
||||
self.max_history_messages,
|
||||
self.max_tool_result_chars,
|
||||
self.compactor.is_some(),
|
||||
Some(self.fs.load()),
|
||||
self.run_context.read().await.as_ref().and_then(|rc| rc.project_root.clone()),
|
||||
));
|
||||
let delegate = DelegateTool::new(manager.clone(), catalog.clone(), store.clone(), MAX_AGENT_DEPTH as u32);
|
||||
catalog.set_delegate(delegate.clone());
|
||||
|
||||
// ── Tool set ──
|
||||
let mut native: Vec<Arc<dyn LoopTool>> = Vec::new();
|
||||
// activate_tools (root scope — shares the config's grant set so the
|
||||
// next round sees the new tools, exactly like today).
|
||||
native.push(Arc::new(
|
||||
ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
|
||||
pool.clone(),
|
||||
self.mcp.clone(),
|
||||
config.active_mcp_grants.clone(),
|
||||
self.session_id,
|
||||
None,
|
||||
)))
|
||||
.with_definition(super::config::activate_tools_tool_def()),
|
||||
));
|
||||
// execute_task: sync → DelegateTool; async → the legacy interface handler.
|
||||
{
|
||||
let et = native_interface(config, tn::EXECUTE_TASK);
|
||||
let (def, handler) = match et {
|
||||
Some(it) => (it.definition.clone(), Some(it.handler.clone())),
|
||||
None => (legacy_execute_task_def(), None),
|
||||
};
|
||||
native.push(Arc::new(ExecuteTaskAliasTool::new(
|
||||
delegate.clone().with_name(tn::EXECUTE_TASK),
|
||||
def,
|
||||
handler,
|
||||
)));
|
||||
}
|
||||
native.push(Arc::new(SkaldAskUserTool::new(
|
||||
Arc::new(SkaldHumanChannel::new(
|
||||
self.clarification.clone(),
|
||||
self.session_id,
|
||||
&config.agent_id,
|
||||
&self.source,
|
||||
self.is_interactive,
|
||||
self.context_label.clone(),
|
||||
)),
|
||||
store.clone(),
|
||||
)));
|
||||
native.push(Arc::new(UpdateScratchpadTool::new(pool.clone(), self.scratchpad_sid())));
|
||||
native.push(Arc::new(WriteTodosTool));
|
||||
|
||||
// Legacy interface tools (per-surface, minus the native ones).
|
||||
let legacy: Vec<InterfaceTool> = config
|
||||
.interface_tools
|
||||
.iter()
|
||||
.filter(|it| {
|
||||
let name = it.definition["function"]["name"].as_str().unwrap_or("");
|
||||
!NATIVE_NAMES.contains(&name)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for it in &legacy {
|
||||
native.push(Arc::new(LegacyInterfaceTool::new(it.clone())));
|
||||
}
|
||||
|
||||
let mut toolset = SkaldToolSet::new(
|
||||
config.base_tool_defs.clone(),
|
||||
config_defs.clone(),
|
||||
self.mcp.clone(),
|
||||
config.active_mcp_grants.clone(),
|
||||
config.memory_tools.clone(),
|
||||
config.image_tools.clone(),
|
||||
legacy,
|
||||
self.tools.all_tools(),
|
||||
)
|
||||
.with_discovery(self.tool_discovery.clone());
|
||||
for t in native {
|
||||
toolset = toolset.with_native(t);
|
||||
}
|
||||
let tools: Arc<dyn ToolSet> = Arc::new(toolset);
|
||||
|
||||
// ── System context ──
|
||||
let system = Arc::new(AgentSystemContext {
|
||||
agent_id: config.agent_id.clone(),
|
||||
extra_static: config.extra_system.clone(),
|
||||
extra_dynamic: config.extra_system_dynamic.clone(),
|
||||
tail_reminder: config.tail_reminder.clone(),
|
||||
substitutions: config.system_substitutions.clone(),
|
||||
pool: pool.clone(),
|
||||
shared_pool: shared_pool.clone(),
|
||||
user_id: self.user_id.clone(),
|
||||
mcp: self.mcp.clone(),
|
||||
project_root: self.run_context.read().await.as_ref().and_then(|rc| rc.project_root.clone()),
|
||||
});
|
||||
|
||||
// ── Assembler ──
|
||||
let assembler = Arc::new(SkaldAssembler {
|
||||
pool: pool.clone(),
|
||||
scratchpad_sid: self.scratchpad_sid(),
|
||||
datetime_config: self.datetime_config.clone(),
|
||||
max_history_messages: self.max_history_messages,
|
||||
max_tool_result_chars: self.max_tool_result_chars,
|
||||
compactor_enabled: self.compactor.is_some(),
|
||||
fs: Some(self.fs.load()),
|
||||
activation: Some(SkaldActivationSource::new(
|
||||
pool.clone(),
|
||||
self.mcp.clone(),
|
||||
config_defs.clone(),
|
||||
self.session_id,
|
||||
None,
|
||||
)),
|
||||
});
|
||||
|
||||
// ── Extensions (tool bridge context) ──
|
||||
let mut extensions = Extensions::new();
|
||||
extensions.insert(pool.clone());
|
||||
extensions.insert(self.fs.load());
|
||||
extensions.insert(Arc::new(CallerUserId(self.user_id.clone())));
|
||||
|
||||
// ── Live input ──
|
||||
let live_input: Option<Arc<dyn LiveInput>> =
|
||||
pending_input.map(|p| Arc::new(PendingLiveInput::new(p.clone())) as Arc<dyn LiveInput>);
|
||||
|
||||
// ── Translator ──
|
||||
let (translator, shared) = EventTranslator::new(
|
||||
tx.clone(),
|
||||
self.tools.clone(),
|
||||
self.mcp.clone(),
|
||||
store.clone(),
|
||||
);
|
||||
let stop = CancellationToken::new();
|
||||
let translator_task = translator.spawn(manager.events(), stop.clone());
|
||||
|
||||
// ── Frame + turn ──
|
||||
let frame = store
|
||||
.open_frame(&conv, None, agent_loop::store::FrameSpec::root(&config.agent_id))
|
||||
.await?;
|
||||
// The frame opened at session provisioning is the one the old path
|
||||
// used — assert the mapping (defensive; remove once bedded in).
|
||||
debug_assert_eq!(frame.get(), stack_id);
|
||||
|
||||
let msg = NewMessage {
|
||||
role: agent_loop::store::Role::User,
|
||||
content: user_content.to_string(),
|
||||
synthetic: is_synthetic,
|
||||
reasoning: None,
|
||||
metadata: metadata.and_then(|m| serde_json::to_value(m).ok()),
|
||||
};
|
||||
let params = TurnParams {
|
||||
frame,
|
||||
agent: config.agent_id.clone(),
|
||||
system,
|
||||
tools,
|
||||
model_hint: ModelHint::name(config.client_name.clone()),
|
||||
live_input,
|
||||
extensions,
|
||||
meta: TurnMeta {
|
||||
synthetic: is_synthetic,
|
||||
interactive: self.is_interactive,
|
||||
..TurnMeta::default()
|
||||
},
|
||||
assembler: Some(assembler),
|
||||
};
|
||||
|
||||
// Register for /stop, then drive.
|
||||
*self.kernel_live.lock().unwrap() = Some((manager.clone(), conv.clone()));
|
||||
let handle = manager.start_turn(conv.clone(), msg, params).await
|
||||
.map_err(|e| anyhow::anyhow!("kernel turn failed to start: {e}"))?;
|
||||
let outcome = handle.join().await;
|
||||
*self.kernel_live.lock().unwrap() = None;
|
||||
stop.cancel();
|
||||
let _ = translator_task.await;
|
||||
|
||||
let shared_state = std::mem::take(&mut *shared.lock().unwrap());
|
||||
|
||||
match outcome? {
|
||||
agent_loop::kernel::TurnOutcome::Final { content, message_id, usage, reasoning } => {
|
||||
let tool_calls: Vec<ToolCallEvent> = shared_state.tool_calls;
|
||||
info!(
|
||||
session_id = self.session_id,
|
||||
user_message_id = ?shared_state.user_message_id,
|
||||
"kernel turn final"
|
||||
);
|
||||
Ok(TurnOutcome::Final {
|
||||
content,
|
||||
message_id: message_id.get(),
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
truncated: usage.truncated,
|
||||
reasoning_content: reasoning,
|
||||
tool_calls,
|
||||
})
|
||||
}
|
||||
agent_loop::kernel::TurnOutcome::Cancelled => Ok(TurnOutcome::Cancelled),
|
||||
agent_loop::kernel::TurnOutcome::Exhausted => Ok(TurnOutcome::Exhausted),
|
||||
}
|
||||
}
|
||||
|
||||
/// `/stop` for the kernel-driven turn: cancels the live loop (the legacy
|
||||
/// `current_cancel` path keeps covering resume/recovery).
|
||||
pub(super) fn cancel_kernel_turn(&self) {
|
||||
let live = self.kernel_live.lock().unwrap().clone();
|
||||
if let Some((manager, conv)) = live {
|
||||
manager.cancel(&conv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds an interface tool by name in the run config.
|
||||
fn native_interface(config: &AgentRunConfig, name: &str) -> Option<InterfaceTool> {
|
||||
config
|
||||
.interface_tools
|
||||
.iter()
|
||||
.find(|it| it.definition["function"]["name"].as_str() == Some(name))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Fallback definition for `execute_task` when no interface handler was
|
||||
/// injected (non-interactive sessions): mirrors the injected one.
|
||||
fn legacy_execute_task_def() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tn::EXECUTE_TASK,
|
||||
"description": "Execute a task with a sub-agent. mode=sync waits for the result; \
|
||||
mode=async schedules it in the background.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": { "type": "string" },
|
||||
"prompt": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"mode": { "type": "string", "enum": ["sync", "async"] },
|
||||
"client": { "type": "string" }
|
||||
},
|
||||
"required": ["agent_id", "prompt"]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -700,12 +700,39 @@ impl MessageBuilder {
|
||||
|
||||
// ── Free helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// `__SHARED_FOLDERS__` section, resolved from the registry (shared with the
|
||||
/// `agent-loop` adapter's system-context source).
|
||||
pub(crate) async fn render_shared_folders_section(
|
||||
shared_pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let rows = crate::db::shared_folders::agent_view(shared_pool, user_id).await?;
|
||||
Ok(render_shared_folders_table(&rows))
|
||||
}
|
||||
|
||||
/// `__USER_PROFILE__` block, resolved from the registry (shared with the
|
||||
/// `agent-loop` adapter's system-context source).
|
||||
pub(crate) async fn render_user_profile_section(
|
||||
shared_pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let user = crate::db::users::get(shared_pool, user_id).await?;
|
||||
let locale = crate::i18n::resolve_locale(
|
||||
shared_pool,
|
||||
user.as_ref().and_then(|u| u.locale.as_deref()),
|
||||
).await;
|
||||
Ok(render_user_profile_block(
|
||||
user.as_ref(),
|
||||
&locale,
|
||||
chrono::Utc::now().date_naive(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Renders the shared-folders section body as a Markdown table — one row per
|
||||
/// folder the user belongs to, naming the folder's other members so the model
|
||||
/// knows exactly who sees what is written there. An empty membership yields an
|
||||
/// explicit "not a member" line so the model does not go probing `shared/` paths.
|
||||
fn render_shared_folders_table(rows: &[crate::db::shared_folders::SharedFolderAccess]) -> String {
|
||||
/// A free-text cell: single line, pipes escaped (they would split the table).
|
||||
fn render_shared_folders_table(rows: &[crate::db::shared_folders::SharedFolderAccess]) -> String { /// A free-text cell: single line, pipes escaped (they would split the table).
|
||||
fn cell(s: &str) -> String {
|
||||
s.trim().replace('|', "\\|").replace('\n', " ")
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ mod config;
|
||||
mod dispatch;
|
||||
mod emitter;
|
||||
mod gate;
|
||||
mod kernel_turn;
|
||||
mod interface_tools;
|
||||
mod llm_call;
|
||||
mod llm_loop;
|
||||
@@ -43,7 +44,6 @@ mod messages;
|
||||
mod outcome;
|
||||
mod resume;
|
||||
|
||||
use emitter::TurnEmitter;
|
||||
|
||||
pub use interface_tools::{InterfaceTool, ToolFuture};
|
||||
|
||||
@@ -54,7 +54,7 @@ pub const DEFAULT_MAX_TOOL_ROUNDS: usize = 20;
|
||||
/// Bounds fan-out so a large batch does not trigger provider rate-limit storms.
|
||||
pub const DEFAULT_MAX_PARALLEL_SUBAGENTS: usize = 4;
|
||||
|
||||
pub(super) const MAX_AGENT_DEPTH: i64 = 5;
|
||||
pub(crate) const MAX_AGENT_DEPTH: i64 = 5;
|
||||
|
||||
/// A queued user message to be appended to history mid-turn (drained from the
|
||||
/// source inbox at a round boundary).
|
||||
@@ -117,14 +117,14 @@ pub(super) enum TurnOutcome {
|
||||
/// lands inside a multi-byte UTF-8 character (e.g. an em-dash or emoji straddling
|
||||
/// the cut point), which is exactly how a well-formed sub-agent result once
|
||||
/// unwound a whole turn. Used for every event/log preview.
|
||||
pub(super) fn preview_truncate(s: &str, max_chars: usize) -> String {
|
||||
pub(crate) fn preview_truncate(s: &str, max_chars: usize) -> String {
|
||||
match s.char_indices().nth(max_chars) {
|
||||
Some((byte_idx, _)) => format!("{}…", &s[..byte_idx]),
|
||||
None => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_scratchpad_tool_def() -> Value {
|
||||
pub(crate) fn update_scratchpad_tool_def() -> Value {
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -154,7 +154,7 @@ pub(super) fn update_scratchpad_tool_def() -> Value {
|
||||
/// agent's own tool-result history. Because conversation history is per-stack,
|
||||
/// it is never visible to sub-agents or to the caller — no DB storage needed.
|
||||
/// The agent re-sends the whole list (TodoWrite-style) on every update.
|
||||
pub(super) fn write_todos_tool_def() -> Value {
|
||||
pub(crate) fn write_todos_tool_def() -> Value {
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -192,7 +192,7 @@ pub(super) fn write_todos_tool_def() -> Value {
|
||||
/// to `dispatch_sub_agent` (the InterfaceTool handler is never reached), so only
|
||||
/// the definition is needed here. `agent_id` is required because
|
||||
/// `dispatch_sub_agent` rejects calls without it.
|
||||
fn execute_subtask_tool_def() -> Value {
|
||||
pub(crate) fn execute_subtask_tool_def() -> Value {
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -215,7 +215,7 @@ fn execute_subtask_tool_def() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn ask_user_clarification_tool_def() -> Value {
|
||||
pub(crate) fn ask_user_clarification_tool_def() -> Value {
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -306,7 +306,7 @@ pub struct ChatSessionHandler {
|
||||
pub(super) clarification: Arc<ClarificationManager>,
|
||||
pub(super) event_bus: Arc<ChatEventBus>,
|
||||
/// Human-readable label injected by background runners (e.g. "CronJob: Daily Digest").
|
||||
pub(super) context_label: std::sync::RwLock<Option<String>>,
|
||||
pub(super) context_label: Arc<std::sync::RwLock<Option<String>>>,
|
||||
pub(super) memory_manager: Arc<MemoryManager>,
|
||||
pub(super) image_generator_manager: Arc<ImageGeneratorManager>,
|
||||
/// Prevents concurrent handle_message calls on the same session.
|
||||
@@ -322,21 +322,24 @@ pub struct ChatSessionHandler {
|
||||
/// When true, any tool call that would require human approval is automatically
|
||||
/// denied instead of blocking. Used by TicManager and other headless runners
|
||||
/// that cannot process approval requests.
|
||||
pub(super) auto_deny_approvals: AtomicBool,
|
||||
pub(super) auto_deny_approvals: Arc<AtomicBool>,
|
||||
/// Tool-call ids the user already approved via a resolve endpoint after a restart
|
||||
/// (no live oneshot to unblock). The next resume's approval gate skips re-gating
|
||||
/// these so a post-restart approve dispatches the tool without a second prompt.
|
||||
pub(super) pre_approved: std::sync::Mutex<std::collections::HashSet<i64>>,
|
||||
pub(super) pre_approved: Arc<std::sync::Mutex<std::collections::HashSet<i64>>>,
|
||||
/// Context compactor, shared across all sessions. `None` when compaction
|
||||
/// is disabled (no `compaction` section in config).
|
||||
pub(super) compactor: Option<Arc<ContextCompactor>>,
|
||||
/// The live kernel-driven turn (manager + conversation) for `/stop`
|
||||
/// routing (phase 2). `None` between turns / on legacy paths.
|
||||
pub(super) kernel_live: std::sync::Mutex<Option<(Arc<agent_loop::manager::LoopManager>, agent_loop::ids::ConversationId)>>,
|
||||
/// Input token count from the most recently completed turn, stored
|
||||
/// atomically so the next `handle_message` call can decide whether to
|
||||
/// compact before processing the new message. Zero means unknown
|
||||
/// (provider did not report usage on the first turn).
|
||||
pub(super) last_input_tokens: AtomicU32,
|
||||
/// Active RunContext for this session. `None` means the "default" group is used implicitly.
|
||||
pub(super) run_context: tokio::sync::RwLock<Option<RunContext>>,
|
||||
pub(super) run_context: Arc<tokio::sync::RwLock<Option<RunContext>>>,
|
||||
/// When set, scratchpad reads/writes use this session_id instead of `self.session_id`.
|
||||
/// Used by async sub-tasks to share the parent's scratchpad.
|
||||
pub(super) scratchpad_session_id: std::sync::OnceLock<i64>,
|
||||
@@ -395,14 +398,15 @@ impl ChatSessionHandler {
|
||||
memory_manager,
|
||||
image_generator_manager,
|
||||
compactor,
|
||||
context_label: std::sync::RwLock::new(None),
|
||||
context_label: Arc::new(std::sync::RwLock::new(None)),
|
||||
processing: Mutex::new(()),
|
||||
current_cancel: std::sync::Mutex::new(CancellationToken::new()),
|
||||
auto_deny_approvals: AtomicBool::new(false),
|
||||
pre_approved: std::sync::Mutex::new(std::collections::HashSet::new()),
|
||||
auto_deny_approvals: Arc::new(AtomicBool::new(false)),
|
||||
pre_approved: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
||||
last_input_tokens: AtomicU32::new(0),
|
||||
run_context: tokio::sync::RwLock::new(run_context),
|
||||
run_context: Arc::new(tokio::sync::RwLock::new(run_context)),
|
||||
scratchpad_session_id: std::sync::OnceLock::new(),
|
||||
kernel_live: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,6 +457,7 @@ impl ChatSessionHandler {
|
||||
/// sub-agent recursion: the token is never reset mid-turn.
|
||||
pub fn cancel(&self) {
|
||||
self.current_cancel.lock().unwrap().cancel();
|
||||
self.cancel_kernel_turn();
|
||||
}
|
||||
|
||||
/// True if a turn is currently in flight (the `processing` mutex is held for
|
||||
@@ -547,7 +552,7 @@ impl ChatSessionHandler {
|
||||
let token = CancellationToken::new();
|
||||
*self.current_cancel.lock().unwrap() = token.clone();
|
||||
let pool = &self.db;
|
||||
let em = TurnEmitter::new(&tx);
|
||||
let user_content = content.to_string(); // saved for the ChatEvent publication
|
||||
|
||||
// Retrieve memory context (Honcho or other backend) for this turn.
|
||||
// Kept SEPARATE from extra_system_context (the static part) so it can be
|
||||
@@ -629,43 +634,30 @@ impl ChatSessionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
let user_content = content.to_string(); // save before TurnOutcome::Final shadows `content`
|
||||
let user_message_id = chat_history::append_with_metadata(pool, stack.id, &chat_history::Role::User, content, is_synthetic, None, metadata.as_ref()).await?;
|
||||
|
||||
// Telnet-style echo: the bubble appears only once the message is persisted.
|
||||
// Synthetic turns (TIC/notification) never produce a user bubble.
|
||||
if !is_synthetic {
|
||||
let attachments = metadata.as_ref().map(|m| m.attachments.clone()).unwrap_or_default();
|
||||
// A custom slash command persists its expanded template (for LLM replay)
|
||||
// but the bubble must show the typed command — emit `display` when present.
|
||||
let echo = metadata.as_ref()
|
||||
.and_then(|m| m.command.as_ref())
|
||||
.map(|c| c.display.clone())
|
||||
.unwrap_or_else(|| user_content.clone());
|
||||
em.user_message(user_message_id, echo, attachments).await;
|
||||
}
|
||||
|
||||
// Resume any tool calls left pending from a previous interrupted session.
|
||||
// They are re-gated (rules may have changed) and executed before the LLM runs.
|
||||
// (Runs before the kernel turn, which appends the user message itself —
|
||||
// resumed results belong to the previous turn and land first.)
|
||||
self.resume_pending_tools(stack.id, &config, &token, &tx).await?;
|
||||
|
||||
let outcome = self.run_agent_turn(stack.id, &config, &token, &tx, pending_input.as_ref()).await?;
|
||||
let outcome = self.run_kernel_turn(
|
||||
stack.id, &config, content, is_synthetic, metadata.as_ref(), pending_input.as_ref(), &tx,
|
||||
).await?;
|
||||
|
||||
match outcome {
|
||||
TurnOutcome::Final { content, message_id, input_tokens, output_tokens, truncated, reasoning_content, tool_calls } => {
|
||||
TurnOutcome::Final { content, message_id, input_tokens, output_tokens, truncated: _, reasoning_content: _, tool_calls } => {
|
||||
// Persist token count so the *next* handle_message call knows
|
||||
// whether to compact before running the LLM loop.
|
||||
if let Some(t) = input_tokens {
|
||||
self.last_input_tokens.store(t, Ordering::Relaxed);
|
||||
}
|
||||
info!(session_id = self.session_id, stack_id = stack.id, ?input_tokens, ?output_tokens, "handle_message done");
|
||||
if truncated {
|
||||
warn!(session_id = self.session_id, ?output_tokens, "response truncated (max_tokens)");
|
||||
em.truncated(output_tokens).await;
|
||||
}
|
||||
em.done(message_id, stack.id, content.clone(), input_tokens, output_tokens, reasoning_content).await;
|
||||
// NB: the WS echo (UserMessage), the Done and — when cut off —
|
||||
// the Truncated events were already emitted by the kernel's
|
||||
// event translator during the turn.
|
||||
|
||||
// Publish both messages to the event bus now that both are in the DB.
|
||||
let user_message_id = shared_user_message_id(&self.db, stack.id, message_id).await;
|
||||
let now = chrono::Utc::now();
|
||||
self.event_bus.user_message(ChatEvent {
|
||||
session_id: self.session_id,
|
||||
@@ -698,14 +690,29 @@ impl ChatSessionHandler {
|
||||
}
|
||||
TurnOutcome::Cancelled => {
|
||||
info!(session_id = self.session_id, "handle_message cancelled by user");
|
||||
em.error("Cancelled by user.".to_string()).await;
|
||||
// The "Cancelled by user." error event was already emitted by
|
||||
// the translator (root LoopEvent::Cancelled).
|
||||
Err(anyhow::anyhow!("Turn cancelled by user"))
|
||||
}
|
||||
TurnOutcome::Exhausted => {
|
||||
error!(session_id = self.session_id, max_rounds = self.max_tool_rounds, "tool-call loop exhausted without final answer");
|
||||
em.error(format!("Exceeded {} tool-call rounds without a final answer.", self.max_tool_rounds)).await;
|
||||
tx.send(ServerEvent::Error {
|
||||
message: format!("Exceeded {} tool-call rounds without a final answer.", self.max_tool_rounds),
|
||||
}).await.ok();
|
||||
Err(anyhow::anyhow!("tool-call loop exhausted after {} rounds without a final answer", self.max_tool_rounds))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The user message of the current turn: the latest User row before the final
|
||||
/// assistant message (used for the ChatEvent publication).
|
||||
async fn shared_user_message_id(pool: &sqlx::SqlitePool, stack_id: i64, _final_id: i64) -> i64 {
|
||||
let history = chat_history::for_stack(pool, stack_id).await.unwrap_or_default();
|
||||
history
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| matches!(m.role, chat_history::Role::User | chat_history::Role::Agent))
|
||||
.map(|m| m.id)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user