agent-loop: projection, recovery, compaction into the crate (phase 3)
Nightly Build / build (push) Successful in 6m49s

The session handler is now a thin shell: three entry points in
kernel_turn.rs (run_kernel_turn / recover_turn / resolve_pending_call)
and the ChatSessionHandler. Everything that shaped a Value — projection,
recovery, compaction mechanics, the LLM loop, message building — lives
in agent-loop or behind a loop_adapters trait.

agent-loop:
- projection/ (mod + media): stored history -> wire messages, the one
  place provider divergence lives; well-formedness contract, DTL
  injections (append-only), media parts. LinearAssembler is now a
  Projection + ProjectionHooks config, not its own implementation
- recovery.rs: reap interrupted batches -> resolve the deepest frame's
  non-terminal calls (Running by policy + RestartHint, AwaitingHuman
  re-asked) -> un-wedge finished children -> cascade up, every frame on
  its own agent (B3)
- compaction.rs: split point (never assistant+tool group), transcript,
  SUMMARY_PREFIX/preamble/template, the no-tools model call, summary row
- manager: resolve_pending (gate skipped, real ToolContext, then
  continue incl. sub-agent); start_loop used by recovery; LiveInput
- delegate: AsyncExecutor + StoreSink for mode:async (durable cron row,
  result delivered back into the parent conversation)
- kernel/context/store: support the above (TurnScope via Extensions,
  frame lookups, aligned result-text semantics)

skald-core:
- loop_adapters: UserLoopRuntime (D12 - one LoopManager per user),
  TurnScope (per-turn state in the Extensions type-map; no scope is
  denied), projection_cfg/media_source/tool_digest (Skald's projection
  knobs without owning projection code), async_task (CronExecutor +
  DurableSink)
- session/handler: stripped to mod.rs + kernel_turn.rs + config.rs +
  interface_tools.rs + media.rs; deleted agent_dispatch, approval,
  dispatch, emitter, gate, llm_call, llm_loop, message_builder,
  messages, outcome, resume
- compactor.rs: policy only (threshold, model pick, CompactionEvent);
  mechanics are the crate's

CLAUDE.md updated (recovery, compaction, sub-agents, approval gate,
projection sections now describe the crate-owned flow).
This commit is contained in:
2026-07-26 17:09:01 +01:00
parent 3fca7867fa
commit 24ee5b89d7
74 changed files with 7661 additions and 5982 deletions
@@ -0,0 +1,408 @@
//! `UserLoopRuntime` — the loop stack of one user, built once.
//!
//! Everything that lives as long as the owner's pool lives here: the
//! `LoopManager` (event bus + live-loop registry), the history store, the
//! approval gate, the hooks, the agent catalog and the delegate tool. A turn
//! then contributes only what is genuinely its own — the agent's prompt, its
//! tool set, its model pin — through [`UserLoopRuntime::turn_params`].
//!
//! Why one per user and not one per turn (blueprint D12): the manager's job is
//! the *global* view — which conversations are running, `/stop`, recovery,
//! shutdown. A manager rebuilt for every message can answer none of those, and
//! rebuilding the graph per message also leaks it (the catalog ↔ delegate cycle
//! is broken by a `Weak`, but a per-turn graph would still pile up).
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;
use agent_loop::tool::{Extensions, Tool as LoopTool, ToolSet};
use core_api::interface_tool::InterfaceTool;
use core_api::user_fs::SharedFs;
use serde_json::Value;
use sqlx::SqlitePool;
use crate::approval::ApprovalManager;
use crate::clarification::ClarificationManager;
use crate::config::DatetimeConfig;
use crate::llm::LlmManager;
use crate::loop_adapters::activation::{SkaldActivationSource, SkaldToolActivator};
use crate::loop_adapters::async_task::CronExecutor;
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::{DtlReanchorHook, SkaldWritePreviewHook};
use crate::loop_adapters::live_input::PendingLiveInput;
use crate::loop_adapters::preview::PreviewContext;
use crate::loop_adapters::projection_cfg::skald_assembler;
use crate::loop_adapters::scope::TurnScope;
use crate::loop_adapters::selector::SkaldSelector;
use crate::loop_adapters::system::AgentSystemContext;
use crate::loop_adapters::toolset::{CallerUserId, SkaldToolSet};
use crate::mcp::McpProvider;
use crate::session::handler::PendingUserInput;
use crate::session::handler::interface_tools::AgentRunConfig;
use crate::tool_discovery::ToolDiscovery;
use crate::tools::ToolRegistry;
use crate::tools::tool_names as tn;
/// Instance-wide loop limits (from `config.yml`).
#[derive(Clone)]
pub struct LoopConfig {
pub max_rounds: usize,
pub max_parallel_calls: usize,
pub max_history_messages: usize,
pub max_tool_result_chars: Option<usize>,
/// Compaction bounds the context instead of a message window.
pub compaction_enabled: bool,
pub datetime: DatetimeConfig,
pub max_agent_depth: u32,
}
/// Names handled natively; a legacy interface tool of the same name is dropped.
const NATIVE_NAMES: &[&str] = &[tn::ACTIVATE_TOOLS, tn::EXECUTE_TASK];
/// One user's loop stack.
pub struct UserLoopRuntime {
manager: Arc<LoopManager>,
store: Arc<dyn HistoryStore>,
catalog: Arc<SkaldAgentCatalog>,
delegate: Arc<DelegateTool>,
/// Backs `execute_task mode=async`; its `TaskManager` lands at wiring time.
async_exec: Arc<CronExecutor>,
// per-turn assembly material
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
fs: SharedFs,
tools: Arc<ToolRegistry>,
mcp: Arc<dyn McpProvider>,
llm_manager: Arc<LlmManager>,
clarification: Arc<ClarificationManager>,
tool_discovery: Arc<ToolDiscovery>,
config: LoopConfig,
}
/// What a turn contributes on top of the runtime.
pub struct TurnInputs<'a> {
pub scope: Arc<TurnScope>,
pub config: &'a AgentRunConfig,
/// Messages queued while the turn runs, drained at round boundaries.
pub live_input: Option<Arc<dyn PendingUserInput>>,
}
impl UserLoopRuntime {
#[allow(clippy::too_many_arguments)]
pub fn build(
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
fs: SharedFs,
tools: Arc<ToolRegistry>,
mcp: Arc<dyn McpProvider>,
llm_manager: Arc<LlmManager>,
approval: Arc<ApprovalManager>,
clarification: Arc<ClarificationManager>,
tool_discovery: Arc<ToolDiscovery>,
config: LoopConfig,
) -> anyhow::Result<Arc<Self>> {
let store: Arc<dyn HistoryStore> = Arc::new(SqliteHistory::new(pool.clone()));
let gate = ApprovalGate::new(
approval.clone(),
store.clone(),
tools.clone(),
pool.clone(),
shared_pool.clone(),
Some(fs.clone()),
);
let preview_hook = Arc::new(SkaldWritePreviewHook::new(PreviewContext {
pool: pool.clone(),
shared_pool: shared_pool.clone(),
fs: Some(fs.clone()),
}));
// The default selector has no strength requirement; every turn overrides
// it with the agent's own (D14).
let default_selector: Arc<dyn ModelSelector> =
Arc::new(SkaldSelector::new(llm_manager.clone(), None));
let manager = Arc::new(
LoopManager::builder()
.models(default_selector)
.store(store.clone())
.gate_arc(Arc::new(gate))
.hook(preview_hook)
.hook(Arc::new(DtlReanchorHook::new(pool.clone())))
.max_rounds(config.max_rounds)
.max_parallel_calls(config.max_parallel_calls)
.build()?,
);
let catalog = Arc::new(SkaldAgentCatalog::new(
pool.clone(),
shared_pool.clone(),
user_id.clone(),
llm_manager.clone(),
approval,
clarification.clone(),
mcp.clone(),
tools.clone(),
fs.clone(),
config.clone(),
));
// `mode: "async"` runs as a durable cron job; the manager behind it is
// set at wiring time (see `CronExecutor`).
let async_exec = Arc::new(CronExecutor::new());
let delegate = Arc::new(
DelegateTool::new(
manager.clone(),
catalog.clone(),
store.clone(),
config.max_agent_depth,
)
.with_async(async_exec.clone()),
);
// The catalog hands `execute_subtask` to children; it holds this Weak.
catalog.set_delegate(&delegate);
Ok(Arc::new(Self {
manager,
store,
catalog,
delegate,
async_exec,
pool,
shared_pool,
user_id,
fs,
tools,
mcp,
llm_manager,
clarification,
tool_discovery,
config,
}))
}
pub fn manager(&self) -> &Arc<LoopManager> {
&self.manager
}
/// Hands the user's `TaskManager` to the async executor. Called once the
/// cron side exists (it needs the session manager that owns this runtime).
pub fn set_task_manager(&self, tasks: Arc<crate::cron::TaskManager>) {
self.async_exec.set_task_manager(tasks);
}
pub fn store(&self) -> &Arc<dyn HistoryStore> {
&self.store
}
/// The conversation id of a session — the store's encoding.
pub fn conversation(session_id: i64) -> ConversationId {
SqliteHistory::conversation(session_id)
}
/// Everything a turn needs, assembled from the run config and the scope.
pub async fn turn_params(&self, inputs: TurnInputs<'_>) -> anyhow::Result<TurnParams> {
let TurnInputs { scope, config, live_input } = inputs;
let frame_agent = config.agent_id.clone();
// ── System context ──
let system = Arc::new(AgentSystemContext {
agent_id: frame_agent.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: self.pool.clone(),
shared_pool: self.shared_pool.clone(),
user_id: self.user_id.clone(),
mcp: self.mcp.clone(),
project_root: scope.project_root.clone(),
scratchpad_sid: scope.scratchpad_sid,
datetime: self.config.datetime.clone(),
});
// ── Tool set: the native tools, then the surface's legacy ones ──
let tools = self.build_toolset(&scope, config);
// ── Assembler: the shared projection, scoped to this session's DTL ──
let assembler = Arc::new(skald_assembler(
Arc::new(SkaldActivationSource::new(
self.pool.clone(),
self.mcp.clone(),
scope.config_defs.clone(),
scope.session_id,
None,
)),
Some(self.fs.load()),
self.config.max_history_messages,
self.config.compaction_enabled,
self.config.max_tool_result_chars,
));
// ── Extensions: the tool bridge's context + the turn's own scope ──
let mut extensions = Extensions::new();
extensions.insert(self.pool.clone());
extensions.insert(self.fs.load());
extensions.insert(Arc::new(CallerUserId(self.user_id.clone())));
extensions.insert(scope.clone());
// ── Selector: this agent's strength (D14) ──
let strength = crate::agents::load_meta(&frame_agent).ok().and_then(|m| m.strength);
let selector: Arc<dyn ModelSelector> =
Arc::new(SkaldSelector::new(self.llm_manager.clone(), strength));
// The session's root frame; the store reuses the provisioned row.
let frame = self
.store
.open_frame(
&Self::conversation(scope.session_id),
None,
agent_loop::store::FrameSpec::root(&frame_agent),
)
.await?;
Ok(TurnParams {
frame,
agent: frame_agent,
system,
tools,
model_hint: ModelHint::name(config.client_name.clone()),
selector: Some(selector),
live_input: live_input
.map(|p| Arc::new(PendingLiveInput::new(p)) as Arc<dyn LiveInput>),
extensions,
meta: TurnMeta {
synthetic: false,
interactive: scope.is_interactive,
context_label: scope.context_label.read().ok().and_then(|g| g.clone()),
user_message: None,
},
assembler: Some(assembler),
})
}
/// The root agent's tool set: natives (activation, delegation, clarification,
/// scratchpad, todos) plus the surface's own interface tools.
fn build_toolset(&self, scope: &Arc<TurnScope>, config: &AgentRunConfig) -> Arc<dyn ToolSet> {
let mut native: Vec<Arc<dyn LoopTool>> = Vec::new();
// activate_tools, sharing the turn's grant set so the next round sees
// whatever this round activated.
native.push(Arc::new(
ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
self.pool.clone(),
self.mcp.clone(),
scope.grants.clone(),
scope.session_id,
None,
)))
.with_definition(crate::session::handler::config::activate_tools_tool_def()),
));
// execute_task: sync/async → the delegate; cron → the scheduling handler.
{
let injected = config
.interface_tools
.iter()
.find(|it| it.definition["function"]["name"].as_str() == Some(tn::EXECUTE_TASK))
.cloned();
let (def, handler) = match injected {
Some(it) => (it.definition.clone(), Some(it.handler.clone())),
None => (legacy_execute_task_def(), None),
};
native.push(Arc::new(ExecuteTaskAliasTool::new(
self.delegate.as_ref().clone().with_name(tn::EXECUTE_TASK),
def,
handler,
)));
}
native.push(Arc::new(SkaldAskUserTool::new(
Arc::new(SkaldHumanChannel::new(
self.clarification.clone(),
scope.session_id,
&scope.agent_id,
&scope.source,
scope.is_interactive,
scope.context_label.clone(),
)),
self.store.clone(),
)));
native.push(Arc::new(UpdateScratchpadTool::new(
self.pool.clone(),
scope.scratchpad_sid,
)));
native.push(Arc::new(WriteTodosTool));
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())));
}
Arc::new(
SkaldToolSet::new(
scope.base_defs.as_ref().clone(),
scope.config_defs.clone(),
self.mcp.clone(),
scope.grants.clone(),
scope.memory_tools.as_ref().clone(),
scope.image_tools.as_ref().clone(),
legacy,
self.tools.all_tools(),
)
.with_discovery(self.tool_discovery.clone())
.with_native_all(native),
)
}
/// The catalog, for callers that list dispatchable agents.
pub fn catalog(&self) -> &Arc<SkaldAgentCatalog> {
&self.catalog
}
}
/// 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"]
}
}
})
}