llm: add dynamic tool loading (DTL) — Kimi system-tools + Anthropic tool-reference
Nightly Build / build (push) Successful in 6m51s

Replace the old session_mcp_grants/stack_mcp_grants table pair with
a single activated_tools table that anchors each activation at the
assistant message_id that triggered it. The durable write moves from
the activate_tools tool itself to the round loop (handle_tool_call),
which has the message_id the DTL serializer positions injected tool
blocks against.

Introduce DtlMode (None / AnthropicToolReference / KimiSystemTools),
resolved per model from capabilities (opt-in via tool_search)
combined with the provider's dtl_format(). The message builder inserts
Kimi system {tools} blocks at the activation position, or emits
Anthropic tool_reference markers on the tool result. The tool-def
surface (all_tool_defs) switches shape: Anthropic declares everything
deferred; Kimi omits activated tools from the top-level array (system
takes over); None keeps the old grant-set logic.

Anthropic client: accept structured system arrays (cache_control on
the static block when DTL is active), carry defer_loading through
conversion, emit tool_reference blocks on result messages. Prompt
caching enabled exactly when DTL is active (anthropic provider).

MCP server list in the prompt is now a static catalogue (not split
Available/Active) — the split invalidated the cache on every activation.
Groundwork for providers.yaml dtl: key; Moonshot/Kimi providers wired
with kimi_system_tools and the k3* enrich now adds tool_search.
Compactor re-anchors activations whose message was compacted away.
This commit is contained in:
2026-07-24 20:48:04 +01:00
parent 3c52587dee
commit d1d0a2af26
21 changed files with 579 additions and 226 deletions
@@ -6,7 +6,7 @@ use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::info;
use crate::db::{chat_history, chat_llm_tools, chat_sessions_stack, scratchpad, stack_mcp_grants};
use crate::db::{activated_tools, chat_history, chat_llm_tools, chat_sessions_stack, scratchpad};
use crate::events::ServerEvent;
use super::{ChatSessionHandler, MAX_AGENT_DEPTH, TurnOutcome};
@@ -108,8 +108,8 @@ impl ChatSessionHandler {
// Sub-agents never inject live user input.
let outcome = self.run_agent_turn(child.id, &child_config, token, tx, None).await;
if let Err(e) = stack_mcp_grants::delete_for_stack(pool, child.id).await {
tracing::warn!(stack_id = child.id, error = %e, "dispatch_sub_agent: failed to delete stack MCP grants");
if let Err(e) = activated_tools::delete_for_stack(pool, child.id).await {
tracing::warn!(stack_id = child.id, error = %e, "dispatch_sub_agent: failed to delete stack activations");
}
let parent_agent_id = parent_config.agent_id.clone();
@@ -165,7 +165,7 @@ impl ChatSessionHandler {
stack_id: i64,
depth: i64,
) -> anyhow::Result<AgentRunConfig> {
let persisted_grants = stack_mcp_grants::list_for_stack(&self.db, stack_id)
let persisted_grants = activated_tools::list_refs_stack(&self.db, stack_id)
.await
.unwrap_or_default();
let active_mcp_grants: Arc<RwLock<HashSet<String>>> =
@@ -205,8 +205,6 @@ impl ChatSessionHandler {
{
let activate_tool = crate::tools::activate_tools::ActivateTools {
pool: Arc::clone(&self.db),
session_id: self.session_id,
stack_id: Some(stack_id),
mcp: Arc::clone(&self.mcp),
active_mcp_grants: Arc::clone(&active_mcp_grants),
@@ -140,7 +140,7 @@ impl ChatSessionHandler {
// Load persisted session grants from DB (MCP server names and/or the reserved
// `config` keyword), then inject `activate_tools` so the LLM can activate
// additional groups on demand.
let persisted = crate::db::session_mcp_grants::list_for_session(
let persisted = crate::db::activated_tools::list_refs_session(
&self.db, self.session_id,
).await.unwrap_or_default();
@@ -148,14 +148,10 @@ impl ChatSessionHandler {
Arc::new(RwLock::new(persisted.into_iter().collect()));
{
let pool_clone = Arc::clone(&self.db);
let session_id = self.session_id;
let mcp_clone = Arc::clone(&self.mcp);
let grants_clone = Arc::clone(&active_mcp_grants);
let activate_tool = crate::tools::activate_tools::ActivateTools {
pool: pool_clone,
session_id,
stack_id: None,
mcp: mcp_clone,
active_mcp_grants: grants_clone,
@@ -3,6 +3,7 @@ use std::sync::{Arc, RwLock};
use serde_json::Value;
use crate::llm::DtlMode;
use crate::mcp::McpProvider;
use crate::tools::Tool;
use crate::tools::tool_names as tn;
@@ -56,10 +57,10 @@ pub struct AgentRunConfig {
pub mcp: Arc<dyn McpProvider>,
/// Set of MCP server names currently granted (activated) for this agent run.
///
/// - Root agents: pre-populated from `session_mcp_grants` DB at config-build time;
/// updated in-place by `activate_tools`.
/// - Root agents: pre-populated from the `activated_tools` table (session-scoped
/// rows) at config-build time; updated in-place by `activate_tools`.
/// - Sub-agents: starts empty; populated by `activate_tools` (stack-scoped, no
/// session leak); deleted from DB when the stack frame terminates.
/// session leak); the frame's rows are deleted when the stack frame terminates.
///
/// May also contain the reserved keyword `"config"`, which unlocks the built-in
/// `Config`-category tools (`config_tool_defs`) rather than an MCP server.
@@ -79,31 +80,39 @@ impl AgentRunConfig {
///
/// Dynamic groups are re-queried every call so that an `activate_tools` call in
/// round N makes the tools visible in round N+1 without rebuilding the whole config.
pub fn all_tool_defs(&self) -> Vec<Value> {
pub fn all_tool_defs(&self, dtl: DtlMode) -> Vec<Value> {
let mut defs = self.base_tool_defs.clone();
// Dynamic groups: read the currently-granted set (MCP server names + `config`).
let granted: HashSet<String> = self.active_mcp_grants
.read()
.map(|g| g.clone())
.unwrap_or_default();
// MCP servers: include tools for the granted server names.
let servers: Vec<String> = granted.iter()
.filter(|n| n.as_str() != crate::tools::tool_names::CONFIG_GROUP)
.cloned()
.collect();
if !servers.is_empty() {
defs.extend(
self.mcp.tools_for(&servers)
.iter()
.map(|t| t.to_openai_definition()),
);
}
// `config` group: include the built-in Config-category tools on demand.
if granted.contains(crate::tools::tool_names::CONFIG_GROUP) {
defs.extend(self.config_tool_defs.iter().cloned());
match dtl {
// Anthropic custom tool_reference: declare EVERY accessible MCP tool +
// the config group as `defer_loading:true`, on every turn. The toolset
// is stable (cache-safe) and `activate_tools` loads the needed ones via
// tool_reference. Deferred defs are excluded from the prompt prefix by
// the API and cost nothing until referenced.
DtlMode::AnthropicToolReference => {
defs.extend(self.mcp.tools().iter().map(|t| deferred(t.to_openai_definition())));
defs.extend(self.config_tool_defs.iter().cloned().map(deferred));
}
// Kimi K3: activated MCP/config tools are injected as `system` messages
// by the message builder, so they are NOT in the top-level tools here.
DtlMode::KimiSystemTools => {}
// Today's behaviour: only the currently-granted MCP servers + config.
DtlMode::None => {
let granted: HashSet<String> = self.active_mcp_grants
.read()
.map(|g| g.clone())
.unwrap_or_default();
let servers: Vec<String> = granted.iter()
.filter(|n| n.as_str() != crate::tools::tool_names::CONFIG_GROUP)
.cloned()
.collect();
if !servers.is_empty() {
defs.extend(self.mcp.tools_for(&servers).iter().map(|t| t.to_openai_definition()));
}
if granted.contains(crate::tools::tool_names::CONFIG_GROUP) {
defs.extend(self.config_tool_defs.iter().cloned());
}
}
}
defs.extend(self.memory_tools.iter().map(|t| t.openai_definition()));
@@ -167,3 +176,12 @@ impl AgentRunConfig {
}
}
}
/// Tags an OpenAI tool definition as deferred (Anthropic tool search): the API
/// keeps it out of the prompt prefix until `activate_tools` references it. The
/// flag rides on the top-level tool object; `AnthropicClient::convert_tools`
/// maps it to Anthropic's native `defer_loading` field.
fn deferred(mut def: Value) -> Value {
def["defer_loading"] = Value::Bool(true);
def
}
@@ -45,7 +45,6 @@ impl ChatSessionHandler {
stack_id: i64,
config: &AgentRunConfig,
active_grants: &HashSet<String>,
tool_defs: &[Value],
req_scope: Option<&str>,
req_strength: Option<LlmStrength>,
cur_name: &mut String,
@@ -57,6 +56,9 @@ impl ChatSessionHandler {
let mut tried_this_round: Vec<String> = vec![cur_name.clone()];
loop {
// Re-derive the tool defs for the model actually serving this attempt:
// a fallback across DTL modes must re-shape (deferred candidates or not).
let cur_tool_defs = config.all_tool_defs(cur_llm.dtl);
let request_id = uuid::Uuid::new_v4().to_string();
let options = ChatOptions {
model: cur_llm.model.clone(),
@@ -72,8 +74,8 @@ impl ChatSessionHandler {
// open directly — keyed on the model actually serving this attempt, so a
// fallback to a text-only model drops the claim. `None` (no media
// capability) leaves the shared defs untouched, avoiding a clone.
let annotated = media_annotated_tools(tool_defs, &cur_llm.capabilities);
let defs: &[Value] = annotated.as_deref().unwrap_or(tool_defs);
let annotated = media_annotated_tools(&cur_tool_defs, &cur_llm.capabilities);
let defs: &[Value] = annotated.as_deref().unwrap_or(&cur_tool_defs);
// Clone the Arc so the in-flight future does not borrow `cur_llm` across
// the fallback reassignment below. On cancel we drop the future
@@ -164,11 +166,13 @@ impl ChatSessionHandler {
// settings (e.g. switching from OpenRouter/Anthropic to DeepSeek)
// or different input capabilities (a non-vision fallback drops
// inline media back to the textual path block).
let activation_stack = if config.depth == 0 { None } else { Some(stack_id) };
match self.build_openai_messages(
&self.db, stack_id, &config.agent_id,
config.extra_system.as_deref(), config.extra_system_dynamic.as_deref(),
config.tail_reminder.as_deref(), active_grants,
&config.system_substitutions, cur_llm.prompt_cache, &cur_llm.capabilities,
cur_llm.dtl, &config.config_tool_defs, activation_stack,
).await {
Ok(m) => *messages = m,
Err(e) => return RoundLlm::Failed(e),
@@ -117,8 +117,11 @@ impl ChatSessionHandler {
// Messages are (re)built with the current model's prompt_cache flag.
// On fallback within the same round `call_llm_round` rebuilds them again
// if the replacement model has a different prompt_cache setting.
let mut messages = self.build_openai_messages(pool, stack_id, &config.agent_id, config.extra_system.as_deref(), config.extra_system_dynamic.as_deref(), config.tail_reminder.as_deref(), &active_grants_snapshot, &config.system_substitutions, cur_llm.prompt_cache, &cur_llm.capabilities).await?;
let tool_defs = config.all_tool_defs();
// Activation scope for the DTL serializer: session-scoped for the root
// agent (stack_id NULL), the frame itself for a sub-agent.
let activation_stack = if config.depth == 0 { None } else { Some(stack_id) };
let mut messages = self.build_openai_messages(pool, stack_id, &config.agent_id, config.extra_system.as_deref(), config.extra_system_dynamic.as_deref(), config.tail_reminder.as_deref(), &active_grants_snapshot, &config.system_substitutions, cur_llm.prompt_cache, &cur_llm.capabilities, cur_llm.dtl, &config.config_tool_defs, activation_stack).await?;
let tool_defs = config.all_tool_defs(cur_llm.dtl);
// Record every tool actually offered to the LLM so the Security-groups
// UI can list/gate dynamically-injected tools. Cheap no-op once each
@@ -128,7 +131,7 @@ impl ChatSessionHandler {
// One LLM call for this round, with automatic model fallback on
// retriable errors. `cur_name`/`cur_llm`/`messages` are updated in place.
let turn_result = match self.call_llm_round(
stack_id, config, &active_grants_snapshot, &tool_defs,
stack_id, config, &active_grants_snapshot,
req_scope.as_deref(), req_strength,
&mut cur_name, &mut cur_llm, &mut messages, token, &em,
).await {
@@ -274,6 +277,25 @@ impl ChatSessionHandler {
DispatchResult::AbortPending => return Ok(CallFlow::End(TurnOutcome::Cancelled)),
};
// Persist the durable effect of `activate_tools`, anchored at the assistant
// `message_id` that triggered it (the anchor the DTL serializer positions
// injected tool blocks against). The in-memory grant set was already updated
// inside the tool; this records it across turns and restarts.
if call.name == crate::tools::tool_names::ACTIVATE_TOOLS {
if let Some(groups) = call.arguments.get("groups").and_then(|g| g.as_array()) {
// Root (depth 0) → session-scoped (stack_id NULL); sub-agent → its frame.
let anchor_stack = if config.depth == 0 { None } else { Some(stack_id) };
for g in groups.iter().filter_map(|v| v.as_str()) {
let kind = if g == crate::tools::tool_names::CONFIG_GROUP { "builtin" } else { "mcp" };
if let Err(e) = crate::db::activated_tools::grant(
pool, self.session_id, anchor_stack, message_id, kind, g,
).await {
tracing::warn!(session_id = self.session_id, group = g, error = %e, "activate_tools: failed to persist activation");
}
}
}
}
match self.record_tool_outcome(
tool_call_id, &call.name, &call.arguments, outcome, preview, em, Some(all_tool_calls),
).await? {
@@ -10,6 +10,7 @@ use core_api::user_fs::UserFs;
use crate::compactor::{ContextCompactor, SUMMARY_PREFIX};
use crate::config::DatetimeConfig;
use crate::db::{chat_history, chat_llm_tools, chat_summaries};
use crate::llm::DtlMode;
use crate::mcp::McpProvider;
use crate::tools::tool_names as tn;
@@ -108,6 +109,12 @@ impl MessageBuilder {
// Input capabilities of the resolved model (`vision`, `video`, …) —
// drives inline media for current-turn attachments.
capabilities: &[String],
// Dynamic-tool-loading mode for the resolved model, plus the config-group
// tool defs (needed to resolve activated tools). `activation_stack` scopes
// the activation read: `None` = session-scoped (root), `Some(id)` = frame.
dtl: DtlMode,
config_tool_defs: &[Value],
activation_stack: Option<i64>,
) -> anyhow::Result<Vec<Value>> {
let pool = &*self.pool;
@@ -254,6 +261,15 @@ impl MessageBuilder {
media_turn_start -= 1;
}
// DTL: resolve the tools activated at each assistant message (empty unless a
// DTL mode is active). Drives the Kimi `system`+`tools` injection and the
// Anthropic `tool_reference` markers emitted in the history loop below.
let activation_defs: HashMap<i64, Vec<Value>> = if matches!(dtl, DtlMode::None) {
HashMap::new()
} else {
self.resolve_activation_defs(activation_stack, config_tool_defs).await
};
for (idx, entry) in history.iter().enumerate() {
let is_previous_turn = current_turn_boundary.is_some_and(|b| idx < b);
@@ -378,11 +394,29 @@ impl MessageBuilder {
tc.arguments.as_deref(),
);
out.push(json!({
let mut tool_msg = json!({
"role": "tool",
"tool_call_id": format!("tc_{}", tc.id),
"content": result_content,
}));
});
// Anthropic DTL: an `activate_tools` result becomes a set of
// `tool_reference`s (the activated groups' tool names) that the
// client renders as tool_reference blocks and the API expands
// into the deferred tools. `_tool_references` is a neutral
// marker other clients ignore.
if matches!(dtl, DtlMode::AnthropicToolReference)
&& tc.name == tn::ACTIVATE_TOOLS
&& let Some(adefs) = activation_defs.get(&entry.id)
{
let names: Vec<Value> = adefs.iter()
.filter_map(|d| d["function"]["name"].as_str())
.map(|n| Value::String(n.to_string()))
.collect();
if !names.is_empty() {
tool_msg["_tool_references"] = Value::Array(names);
}
}
out.push(tool_msg);
}
// Media a tool produced this turn (e.g. read_file on an
@@ -411,6 +445,16 @@ impl MessageBuilder {
}
}
}
// Kimi K3 DTL: inject the tools activated at this assistant
// message as a `system` message carrying a `tools` field, right
// after its tool-result group (append-only → cache-safe).
if matches!(dtl, DtlMode::KimiSystemTools)
&& let Some(adefs) = activation_defs.get(&entry.id)
&& !adefs.is_empty()
{
out.push(json!({ "role": "system", "tools": adefs }));
}
}
}
}
@@ -585,7 +629,49 @@ impl MessageBuilder {
))
}
fn render_mcp_list(&self, active_mcp_grants: &HashSet<String>) -> String {
/// Resolves the tools activated at each assistant message, for DTL
/// serialization. Returns `message_id → deduped OpenAI tool defs`. MCP groups
/// resolve to the server's live tool defs; the `config` group resolves to the
/// passed-in config-category defs. Scope mirrors the in-memory grant set:
/// `None` = session-scoped (root), `Some(id)` = the sub-agent frame.
async fn resolve_activation_defs(
&self,
activation_stack: Option<i64>,
config_tool_defs: &[Value],
) -> HashMap<i64, Vec<Value>> {
let activations = crate::db::activated_tools::list_active_at(
&self.pool, self.session_id, activation_stack, i64::MAX,
).await.unwrap_or_default();
let mut map: HashMap<i64, Vec<Value>> = HashMap::new();
for a in activations {
let resolved: Vec<Value> = match a.kind.as_str() {
"mcp" => self.mcp.tools_for(&[a.ref_.clone()])
.iter().map(|t| t.to_openai_definition()).collect(),
"builtin" if a.ref_ == tn::CONFIG_GROUP => config_tool_defs.to_vec(),
_ => Vec::new(),
};
let entry = map.entry(a.message_id).or_default();
for d in resolved {
if let Some(name) = d["function"]["name"].as_str().map(str::to_string) {
if !entry.iter().any(|e| e["function"]["name"].as_str() == Some(name.as_str())) {
entry.push(d);
}
}
}
}
map
}
/// A **static** catalogue of the MCP servers this user can load — identical
/// regardless of which are currently active. Static is deliberate: this text
/// sits inside the `cache_control: ephemeral` system block, so the old
/// Available/Active split (which moved a server between tables on activation)
/// invalidated the prompt-cache prefix on every `activate_tools` call. Which
/// servers are active is already visible to the model through the injected
/// tools. The grant set is retained in the signature only for call-site
/// stability (the DTL serializer may consume it later).
fn render_mcp_list(&self, _active_mcp_grants: &HashSet<String>) -> String {
let all_servers: std::collections::BTreeSet<String> = self.mcp.tools()
.into_iter()
.map(|t| t.server_name)
@@ -597,37 +683,17 @@ impl MessageBuilder {
let descriptions = self.mcp.server_descriptions();
let hidden: Vec<&String> = all_servers.iter()
.filter(|n| !active_mcp_grants.contains(*n))
.collect();
let active: Vec<&String> = all_servers.iter()
.filter(|n| active_mcp_grants.contains(*n))
.collect();
let mut out = String::from("## MCP servers\n");
if !hidden.is_empty() {
out.push_str("\n**Available** — call `activate_tools([\"name\"])` to load tools:\n\n");
out.push_str("| Server | Description |\n|--------|-------------|\n");
for name in &hidden {
let desc = descriptions.get(*name)
.and_then(|d| d.as_deref())
.unwrap_or("");
out.push_str(&format!("| `{name}` | {desc} |\n"));
}
let mut out = String::from(
"## MCP servers\n\nConnectors you can load with `activate_tools([\"name\"])`. \
Once loaded, a server's tools are callable as `mcp__<name>__<tool>`:\n\n",
);
out.push_str("| Server | Description |\n|--------|-------------|\n");
for name in &all_servers {
let desc = descriptions.get(name)
.and_then(|d| d.as_deref())
.unwrap_or("");
out.push_str(&format!("| `{name}` | {desc} |\n"));
}
if !active.is_empty() {
out.push_str("\n**Active** — tools callable as `mcp__<name>__<tool>`:\n\n");
out.push_str("| Server | Description |\n|--------|-------------|\n");
for name in &active {
let desc = descriptions.get(*name)
.and_then(|d| d.as_deref())
.unwrap_or("");
out.push_str(&format!("| `{name}` | {desc} |\n"));
}
}
out
}
}
@@ -3,6 +3,7 @@ use std::sync::Arc;
use serde_json::Value;
use crate::llm::DtlMode;
use super::ChatSessionHandler;
use super::message_builder::MessageBuilder;
@@ -23,6 +24,9 @@ impl ChatSessionHandler {
system_substitutions: &HashMap<String, String>,
cache_hints: bool,
capabilities: &[String],
dtl: DtlMode,
config_tool_defs: &[Value],
activation_stack: Option<i64>,
) -> anyhow::Result<Vec<Value>> {
let project_root = self.run_context.read().await
.as_ref()
@@ -45,6 +49,6 @@ impl ChatSessionHandler {
// `pool` is passed in from the caller (always `&self.db`) but we take
// ownership via Arc::clone above so the signature stays backward-compatible.
let _ = pool; // suppress unused-variable warning; MessageBuilder uses its own Arc
builder.build(stack_id, agent_id, extra_system_static, extra_system_dynamic, tail_reminder, active_mcp_grants, system_substitutions, cache_hints, capabilities).await
builder.build(stack_id, agent_id, extra_system_static, extra_system_dynamic, tail_reminder, active_mcp_grants, system_substitutions, cache_hints, capabilities, dtl, config_tool_defs, activation_stack).await
}
}