Commit Graph
7 Commits
Author SHA1 Message Date
dguiducci efb5b1dc33 feat: let an agent ask what its connectors are, instead of guessing
Nightly Build / build (push) Successful in 7m44s
An agent that wanted to know which MCP servers it had called
`list_mcp_servers` — a tool that has never existed anywhere in this
repo — and got "unknown tool". It was not a random hallucination: the
prompt block says "the system prompt shows available servers", and
`render_mcp_list` returned an empty string when nothing was connected.
The model read a promise, found no table, and invented the discovery
tool the text implied. The `mcp` kinds of `list_items`/`toggle_item`
had been removed to close the §14 RCE vector, which was right for the
write half and left no read half at all.

So `list_items` gains `type: "mcp"` and returns the whole picture in
one call, split into four buckets that each answer a different
question: what is already loaded (call its tools directly), what is
ready for `activate_tools`, what is installed but unusable and why,
and what the user could still activate. Conflating the first two is
what produced the original failure, so they stay apart. Every entry
carries a derived note and a next step; when the step is a human one,
it says so and names the UI page, because there is no tool for it.

Read-only, and structurally so: `toggle_item` deliberately gains
nothing, and the new `McpDirectory` trait exposes exactly one method.
Enabling a connector from a tool is the thing §14 removed, and a wider
seam here is how it would come back. Deny-by-default survives the
report — an ungranted connector is not named at all, since a listing
of what to ask for is itself a leak — except for a catalogue manager,
who cannot administer what they cannot see.

Three sources answer three questions and none is redundant: the
registry says what exists and who may have it, the owner database says
what was activated, and the live runtimes say what is connected right
now — a row can read `ready` while its process is dead. The live half
reaches the tool through the turn's extension map, alongside the pool
and the fs view; with no live view the durable picture still renders,
so freshness is an improvement and never a precondition.

The static `__MCP_LIST__` table stays as it was, because it is frozen
per conversation for prompt-cache stability. Its empty case now says
so out loud and points at the tool.
2026-08-04 19:41:18 +01:00
dguiducci 6cb4ea0ce8 feat: let the fs-tools reach the whole container, and stop rebuilding the system prefix every round
Nightly Build / build (push) Successful in 7m33s
Two changes to what a turn costs and what it can see.

## The system prefix is frozen per conversation

`AgentSystemContext::system_context` is called once per round and reassembled
`base` from disk and SQLite each time, so an agent writing `user-memory/index.md`
in round 3 turned round 4 — seconds later, with the provider cache certainly
warm — into a full miss. `base` is the head of every provider's cache key, so it
is the most expensive string in the request to touch.

`PrefixCache` builds it once per (conversation, agent) and holds it on
`UserLoopRuntime`. The refresh rule is the only free one: rebuild once the
conversation has been idle longer than a provider's cache could survive
(20 min). The clock is idle time of the conversation, not time since a file
changed, and reading restarts it — every get is a request about to go out.

Writes are deliberately not reacted to. The agent's own edits are already in the
context, two messages downstream. A write from elsewhere is invisible until the
TTL: that is precisely where an immediate rebuild costs the most, and the
cheaper freshness path already exists — a `read_file` result appends, and
appending invalidates nothing. The injection header now says so.

Also removes 4 DB queries and 2 file reads per round.

## The security boundary is the container, not the mounted subtree

`read_file /tmp/cv.txt` answered "path escapes your workspace" and the agent
re-read the file with `cat`. It was right to refuse — /tmp exists only inside
the container — but the refusal protected nothing: `execute_cmd` already runs
there with passwordless sudo. The mount is the fast path, not the perimeter.

`resolve_target` now routes an absolute path through `container_to_agent`
first. Landing on a mount takes the host path, which also fixes a real bug:
`/root/x` IS `~/x`, yet every tool rejected it, because `PathBuf::join` with an
absolute tail discards the base and the result then failed the prefix check
(`/root/shared/{X}/…` too). Landing nowhere means container-only, served by the
new `container::exec_fs` over `docker exec`, with paths passed positionally so
a path containing `$(…)` stays data. Membership still holds: `/root/shared/{X}`
for a non-member fails exactly as `shared/{X}` does.

Single-file tools get this without a second implementation: `fs::Shuttle` pulls
the file out, runs the unchanged tool on the copy, and pushes it back if the
bytes changed. `list_files` lists in place, `read_file` reads container paths as
text (a shuttled copy cannot back a MediaRef), and `grep_files` refuses them
with a pointer to `rg` rather than approximating its own semantics. The viewer
follows the same routing, so the user can open what the agent read.

Host containment is untouched and still guards every mounted path — it is the
defence against a symlink planted in the container pointing at the host's /etc,
and the container branch never touches the host filesystem at all.

Verified end-to-end against a live skald-runtime:v3 container: write/read/edit
on /tmp round-trip, /etc/os-release reads, binary and shell-metacharacter paths
survive, and /root/notes.md lands in the host home.
2026-08-04 12:42:04 +01:00
dguiducci baf68878e4 fix: stop shrinking conversations behind the user's back — both automatic context guards ship off
Nightly Build / build (push) Successful in 7m35s
The shipped default combined a sliding history window with no compaction, which
is the worse of the two available trades in both directions it is measured on.

`max_history_messages: 30` is a sliding tail window (`projection::window` —
`drain(..len - max)`). Past 30 messages it drops from the head on *every* turn,
so the prompt prefix changes on every single request and every provider that
caches one (Anthropic breakpoints, OpenAI automatic prefix caching) misses every
time. It also drops those messages with no summary standing in for them: silent
amnesia, not just a cold cache. Compaction rewrites the prefix once per
compaction and leaves a summary behind — yet it was the half that was commented
out, while the window's own doc-comment already said the two were exclusive.

Both are now `Option` and both ship unset, so nothing shrinks a conversation
unless a human types `/compact`.

Which surfaced the real bug: `/compact` did not work either. The compactor was
`Option<Arc<ContextCompactor>>` keyed on the config section existing, so
commenting out `compaction:` disabled the manual command too — `force_compact`
returned `Ok(false)` and the chat answered "compaction disabled". Manual
compaction is a command a user types; it cannot depend on an admin having filled
in a token threshold. The compactor is now built unconditionally and
`threshold_tokens: Option<u32>` arms only the automatic pass; `try_compact`
early-returns without it, `force_compact` deliberately never consults it.

The projection accordingly yields to the *automatic* pass rather than to the
compactor's existence (`LoopConfig.auto_compaction_enabled`), so a configured
message cap is not silently voided by `/compact` merely being available.
`CompactionConfig::Default` is hand-written for the same reason `RoleAttrs`'s is:
a derived one gives `keep_recent: 0`, which would compact away every recent
message on any box omitting the section — now the default.

Also fixes two documentation bugs in the same file: `event_triage` was documented
nested under `llm:`, where it parses fine and is then silently ignored (it is a
top-level field), and `datetime` was documented twice with conflicting examples.

A new test asserts the shipped default actually deserializes and that both guards
are off — a field the default omits must be genuinely optional, or a brand-new
install fails to boot.

Automatic compaction returns later, triggered off the resolved model's own
context window instead of a hand-tuned token count that cannot know which model
is answering.
2026-08-02 21:21:14 +01:00
dguiducci 4f10528368 feat: conversation review — a nightly report on a supervised person's conversations
Nightly Build / build (push) Successful in 7m40s
The first AgentScope::PerSubject system agent, and the reason that scope
exists. Once a night, for each person with a supervision edge, it reads every
message that person and the assistant exchanged since the previous review —
across all their conversations — and writes one report for the people who
supervise them.

Schema (all registry except reports):
- supervision(subject_user_id, supervisor_user_id): the generic §0.1 edge,
  answering both 'whom does a background agent look at' and 'who may read
  what it produced', with real FKs so deleting a user cascades both ways
- system_agent_coverage(agent_id, subject_user_id, covered_through): the
  per-subject watermark that makes 'everything since last time' a window —
  neither system_agent_runs (history for humans) nor system_agent_state
  (advances before the work), and advanced only on a completed pass so a
  crash re-covers instead of skipping
- reports (owner schema, the second two-homes table after memory_docs):
  instance rows land in system.db, deliberately cleartext to the box owner,
  who is the intended reader (§2); the subject cannot see them structurally

The pass reads the subject's database inside a supervisor's runtime, so the
ephemeral session and run row land in the watcher's file; iteration is over
subjects, so two parents watching one child get one review; and the subject
need not be logged in when their space is unencrypted — via the new
UserManager::open_unencrypted, which refuses an encrypted user outright (no
key to be had) and never registers the pool as unlocked.

The agent declares the new AgentMeta flag allow_tools: false, so its turn
gets an empty tool registry — nothing for a prompt injection in the
transcript to call — and produces its report as its final assistant message,
read back from chat_history and parsed (NOTHING_TO_REPORT sentinel, no row on
quiet days). chat_history::conversation_window is the transcript query; its
four filters (non-ephemeral, depth 0, non-synthetic, non-empty) each guard a
specific way the review would otherwise be wrong, and tool calls are absent
by construction.

Cadence is Run at (hour) rather than Interval — 4am local by default — with
due-ness answered inside has_work against the coverage watermark, so a
machine off for three days covers the whole stretch in one pass. Reports
announce ReportCreated on the system bus (no subscriber yet). run_ephemeral_turn
gains a per-pass system_substitutions map, which the review uses to hand the
model the subject's profile under __SUBJECT_PROFILE__ — the system-context
substitutions describe the session owner, the wrong person here.

docs/system-agents.md gains the conversation review section; CLAUDE.md
documents the scope, the tables and the tool-less design.
2026-08-02 20:30:27 +01:00
dguiducci 6f35c53d93 activate_tools: diagnose a group instead of pretending it activated
A group name that resolved to no running MCP server was granted anyway,
persisted in `activated_tools`, and reported as a success with "registered
but not yet running — tools will appear after reconnect". Every part of
that was false: nothing registers connectors anymore (the agent-facing
`register_mcp` went away with §14), no reconnect will ever produce the
tools, and the model — believing it had succeeded — called `mcp__x__…` a
round later and failed there instead of here. The junk grant row stayed in
the session forever, resolving to zero tool defs on every projection.

`SkaldToolActivator` now resolves first and acts only on what resolved,
walking the connector states in order: the built-in `config` group, then
the servers running in this user's view, then `mcp_user_servers` (owner
pool), then `mcp_global_servers` + `mcp_global_access`, then `mcp_catalog`
+ `mcp_catalog_access` (registry pool), then unknown. Only `activated`
touches the in-memory grant set and the DB; everything else leaves no
trace at all.

The result is a JSON object keyed by group name — `status` (activated /
needs_login / not_activated / not_authorized / unavailable / unknown),
`tool_prefix`, `tool_count`, `description`, `message` — with the same
shape whether the call succeeded or not, so the model parses one thing
rather than prose. `message` is written to be relayed to a non-technical
user and says who can fix it: the user in Connectors, or the admin. When
no group at all activates, the tool fails with that same JSON, so the
model reports the diagnosis instead of proceeding. A diagnosis query that
errors is logged and falls through to the next candidate — a broken lookup
must never become a false claim about a connector.

The activator needs the registry pool, the user id and the config defs;
both construction sites are updated, so a sub-agent gets the same
diagnosis as the root agent.

Removes `tools/activate_tools.rs` and its interface-tool registration: it
was unreachable (`SkaldToolSet::find` prefers natives, and `NATIVE_NAMES`
explicitly drops the legacy interface tool of that name) but carried the
same wrong string, waiting to be fixed twice.
2026-07-26 21:41:53 +01:00
dguiducciandClaude Opus 5 73c720e9ef llm: restore request logging lost in the agent-loop migration
Nightly Build / build (push) Successful in 6m50s
The LLM-requests page has been empty since 24ee5b8: deleting
`session/handler/llm_call.rs` dropped both halves of the request log.

The kernel builds the `ModelRequest` itself and sets `log: None`, so the
`LoggingModel` decorator — wrapped once per model by `LlmManager` — wrote
every metadata row with a NULL `user_id`, while the page (and the detail
endpoint) filter on it. Nothing wrote `llm_request_payloads` at all any
more, so the payload viewer had nothing to show either.

Correlation cannot come from `LlmManager`: it builds one shared client
per model and does not know whose traffic it serves. It now comes from
the `ModelSelector`, the one component that knows both the model and the
owner: `SkaldSelector::with_log(RequestLogTarget)` wraps the model it
hands out, so metadata lands in `llm_requests` attributed to the user and
the payload lands in that user's own encrypted DB, keyed by `request_id`.
Session and frame are read off the request's `conversation`/`frame`,
which makes kernel rounds, sub-agent frames and compaction summaries all
attributed with no extra plumbing (`ModelRequest::log` stays unused).

The compactor's summariser call is attributed too, which it never was:
`try_compact`/`force_compact` now take the owner (its selector is built
per compaction, so it can carry the target).

Three tests in `llm::logging` lock this down — the owner/session/frame
columns, the error row with the provider's rejected body, and the
metadata-only path when no owner pool is available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:31:49 +01:00
dguiducci 24ee5b89d7 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).
2026-07-26 17:09:01 +01:00