diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 640ff34f7f..a186f703d3 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -665,7 +665,9 @@ jobs: openhuman/agent/harness/builtin_definitions_tests.rs openhuman/agent/harness/definition_tests.rs openhuman/agent/harness/mod.rs + openhuman/agent/harness/session/builder/factory.rs openhuman/agent/harness/session/session_tests_part_01_tests.rs + openhuman/agent/harness/session/turn/tools.rs openhuman/agent/harness/subagent_runner/tool_prep_tests.rs openhuman/agent/registry/agents/loader.rs openhuman/agent/registry/agents/loader_tests_part_01_tests.rs @@ -677,7 +679,9 @@ jobs: openhuman/mcp/server/tools/mod.rs openhuman/platform/socket/event_handlers.rs openhuman/platform/socket/ops.rs + openhuman/skills/bundled/mod.rs openhuman/skills/mod.rs + openhuman/skills/search.rs openhuman/tools/impl/network/http_request.rs openhuman/tools/ops.rs openhuman/tools/ops_tests.rs diff --git a/app/src/components/flows/FlowListRow.test.tsx b/app/src/components/flows/FlowListRow.test.tsx index decb5582e8..0e8c4db316 100644 --- a/app/src/components/flows/FlowListRow.test.tsx +++ b/app/src/components/flows/FlowListRow.test.tsx @@ -18,6 +18,7 @@ function makeFlow(overrides: Partial = {}): Flow { return { id: 'flow-1', name: 'Daily digest', + description: '', enabled: true, graph: { nodes: [], edges: [] }, created_at: '2026-01-01T00:00:00Z', diff --git a/app/src/pages/FlowsPage.test.tsx b/app/src/pages/FlowsPage.test.tsx index ebee0fbd91..7d1870d5a2 100644 --- a/app/src/pages/FlowsPage.test.tsx +++ b/app/src/pages/FlowsPage.test.tsx @@ -61,6 +61,7 @@ function makeFlow(overrides: Partial = {}): Flow { return { id: 'flow-1', name: 'Daily digest', + description: '', enabled: true, graph: { nodes: [], edges: [] }, created_at: '2026-01-01T00:00:00Z', diff --git a/app/src/pages/__tests__/FlowCanvasPage.test.tsx b/app/src/pages/__tests__/FlowCanvasPage.test.tsx index 4d0dc84cb4..9e37007875 100644 --- a/app/src/pages/__tests__/FlowCanvasPage.test.tsx +++ b/app/src/pages/__tests__/FlowCanvasPage.test.tsx @@ -81,6 +81,7 @@ function makeFlow(overrides: Partial = {}): Flow { return { id: 'test-id', name: 'Daily digest', + description: '', enabled: true, graph: { schema_version: 1, diff --git a/app/src/services/api/flowsApi.ts b/app/src/services/api/flowsApi.ts index 24b8b10390..42685b788c 100644 --- a/app/src/services/api/flowsApi.ts +++ b/app/src/services/api/flowsApi.ts @@ -135,6 +135,15 @@ export interface Flow { id: string; /** Human-readable name shown in the Workflows UI. */ name: string; + /** + * One line saying what this automation is for. + * + * Empty is normal and must be rendered as such, not as a missing value: + * every flow saved before this field existed has none, and the canvas does + * not require one. Surfaced in the agent's skills catalogue, where an empty + * description falls back to describing the graph's shape. + */ + description: string; /** Whether this flow may currently be triggered/run. */ enabled: boolean; /** The validated, migrated workflow graph — opaque to this client. */ @@ -237,6 +246,8 @@ export interface FlowConnection { /** Optional fields for {@link updateFlow}. Omitted fields are left untouched. */ interface FlowUpdate { name?: string; + /** Omit to leave the stored description untouched; `''` clears it. */ + description?: string; graph?: unknown; requireApproval?: boolean; /** @@ -373,16 +384,14 @@ function unwrapCliEnvelope(payload: unknown): T { export async function createFlow( name: string, graph: unknown, - requireApproval?: boolean + requireApproval?: boolean, + description?: string ): Promise { log('createFlow: request name=%s requireApproval=%s', name, requireApproval ?? 'default'); - const response = await callCoreRpc({ - method: 'openhuman.flows_create', - params: - requireApproval === undefined - ? { name, graph } - : { name, graph, require_approval: requireApproval }, - }); + const params: Record = { name, graph }; + if (requireApproval !== undefined) params.require_approval = requireApproval; + if (description !== undefined) params.description = description; + const response = await callCoreRpc({ method: 'openhuman.flows_create', params }); const flow = unwrapCliEnvelope(response); log('createFlow: response id=%s name=%s enabled=%s', flow.id, flow.name, flow.enabled); return flow; @@ -650,6 +659,7 @@ export async function updateFlow(id: string, update: FlowUpdate): Promise ); const params: Record = { id }; if (update.name !== undefined) params.name = update.name; + if (update.description !== undefined) params.description = update.description; if (update.graph !== undefined) params.graph = update.graph; if (update.requireApproval !== undefined) params.require_approval = update.requireApproval; if (update.expectedVersion !== undefined) params.expected_version = update.expectedVersion; diff --git a/scripts/check-prompt-budget.sh b/scripts/check-prompt-budget.sh new file mode 100755 index 0000000000..1e2d762704 --- /dev/null +++ b/scripts/check-prompt-budget.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# Enforce the fixed-prefix ratchet declared in `scripts/prompt-budget.limits`. +# +# Every turn ships a system prompt plus the schemas of every advertised tool, +# before the user has said anything. On this codebase that prefix reached ~37k +# tokens for the orchestrator, ~44k of which was tool schema that nothing in the +# repo measured — the whole of the prior discussion had been about prose, +# because prose was the half that was visible. This lane is the number. +# +# Like `check-kernel-floor.sh`, the ratchet only goes DOWN: it fails on growth, +# and it fails when a profile comes in *under* its limit by more than a slack +# margin without the limit being lowered, because a saving nobody ratchets is a +# saving that silently grows back. +# +# Usage: scripts/check-prompt-budget.sh [--verbose] [--write] +# +# --write Rewrite the limits file's numbers to the measured values. For +# landing a deliberate reduction; never run it to make CI green. +# +# Two entry shapes, both ratchets: +# :: +# tool:: - one tool's own schema +# +# The per-tool ratchet exists because the per-agent number hides its own +# causes: `workflow_builder` sat at 33,948 B with a single tool accounting for +# 7,415 of it. A tool over TOOL_ATTENTION_BYTES must be recorded in the limits +# file, so growth in one schema cannot hide inside an agent total that is still +# under its own limit. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +LIMITS="scripts/prompt-budget.limits" +VERBOSE=0 +WRITE=0 +for arg in "$@"; do + case "$arg" in + --verbose) VERBOSE=1 ;; + --write) WRITE=1 ;; + *) echo "unknown flag: $arg" >&2; exit 64 ;; + esac +done + +# Slack in bytes, per measured field. +# +# Prompt text moves by a few bytes for reasons nobody should have to ratchet — +# a typo fix, a renamed heading. Tool schemas do not move at all unless a schema +# changed. 512 absorbs ordinary copy-editing (~128 tokens) while still forcing a +# ratchet update for any real reduction, which starts in the thousands. +SLACK=512 + +# The size at which one tool's schema is worth an explicit decision. +# +# 1,600 bytes is ~400 tokens, the point at which a schema costs more than most +# prose sections of the system prompt. Crossing it is not a bug; crossing it +# without anyone noticing is. A tool above this line must appear in the limits +# file, which is where the reason goes. +# +# Deliberately a ratchet rather than a hard cap. A cap failing above ~800 +# tokens would reject `memory` (3,788 B) and `cron` (3,228 B), which are that +# size precisely because they replaced families of eleven and six - the +# consolidation this budget exists to encourage. What matters is that a number +# moves and someone looks, not that every tool fits one shape. +TOOL_ATTENTION_BYTES=1600 + +# The measurement must not depend on who is logged in. +# +# `prompt-size` renders through the real session path, which injects the +# workspace's PROFILE.md / MEMORY.md / AGENTS.md and fetches live Composio +# connections. Run against a developer's own workspace the numbers move with +# their memory tree, which would make this lane fail for reasons unrelated to +# any change. A fresh empty workspace is the only reproducible baseline; it also +# means `integrations_agent` has no connected toolkit and is skipped, which is +# correct — its size is a property of the user's account, not of this repo. +WORKSPACE="$(mktemp -d "${TMPDIR:-/tmp}/openhuman-prompt-budget.XXXXXX")" +cleanup() { rm -rf "$WORKSPACE" "${TOOL_LOOKUP:-}" "${TOOL_OVERSIZE:-}"; } +trap cleanup EXIT + +BIN="target/debug/openhuman-core" +if [[ ! -x "$BIN" ]]; then + echo "[prompt-budget] building openhuman-core …" >&2 + cargo build --manifest-path Cargo.toml --bin openhuman-core \ + --features "$(bash scripts/ci/product-features.sh)" >&2 +fi + +echo "[prompt-budget] measuring against hermetic workspace $WORKSPACE" >&2 +if ! measured="$(RUST_LOG=error "$BIN" agent prompt-size --workspace "$WORKSPACE/workspace" --hermetic --json)"; then + echo "::error::prompt-size failed to measure" >&2 + exit 1 +fi + +# Helper python written to temp files rather than inline heredocs: a heredoc +# inside the `while read` loop would consume the loop's own stdin. +TOOL_LOOKUP="$(mktemp)" +TOOL_OVERSIZE="$(mktemp)" +cat > "$TOOL_LOOKUP" <<'PYLOOKUP' +import json, os, sys +d = json.loads(sys.argv[1]) +tool = os.environ["TOOL"] +for agent in d["agents"]: + for t in agent["tools"]: + if t["name"] == tool: + print(t["bytes"]) + sys.exit(0) +sys.exit(f"tool '{tool}' is in prompt-budget.limits but no agent advertises it - " + f"was it renamed, removed or deferred? Drop the line if so.") +PYLOOKUP +cat > "$TOOL_OVERSIZE" <<'PYOVER' +import json, os, sys +d = json.loads(sys.argv[1]) +listed = set(filter(None, os.environ["LISTED"].split())) +threshold = int(os.environ["THRESHOLD"]) +seen = {} +for agent in d["agents"]: + for t in agent["tools"]: + seen[t["name"]] = t["bytes"] +for name, size in sorted( + ((n, b) for n, b in seen.items() if b > threshold and n not in listed), + key=lambda kv: -kv[1], +): + print(f"{name} {size}") +PYOVER + +status=0 +declare -a NEW_LINES=() + +while IFS= read -r raw; do + line="${raw%%#*}" + line="$(echo "$line" | tr -d '[:space:]')" + if [[ -z "$line" ]]; then + NEW_LINES+=("$raw") + continue + fi + + IFS=: read -r first second third extra <<< "$line" + if [[ -n "${extra:-}" || -z "$first" || -z "$second" || -z "$third" ]]; then + echo "::error::invalid prompt-budget limit entry: '$line'" >&2 + exit 1 + fi + + if [[ "$first" == "tool" ]]; then + tool_name="$second" + max_tool="$third" + if ! measured_bytes="$(TOOL="$tool_name" python3 "$TOOL_LOOKUP" "$measured")"; then + status=1 + NEW_LINES+=("$raw") + continue + fi + if (( VERBOSE )); then echo "tool $tool_name $measured_bytes/$max_tool"; fi + if (( measured_bytes > max_tool )); then + echo "::error::tool schema REGRESSED: '$tool_name' is $measured_bytes B, limit is" \ + "$max_tool. Every agent holding it pays this on every turn." >&2 + status=1 + elif (( max_tool - measured_bytes > SLACK )); then + echo "::error::tool schema IMPROVED but was not ratcheted: '$tool_name' is" \ + "$measured_bytes B against a limit of $max_tool. Lower it in this PR." >&2 + status=1 + fi + NEW_LINES+=("tool:$tool_name:$measured_bytes") + continue + fi + + agent="$first" + max_prompt="$second" + max_tools="$third" + + if ! read -r prompt_bytes tool_bytes tool_count max_tool_bytes max_tool_name <<< "$( + AGENT="$agent" python3 - "$measured" <<'PY' +import json, os, sys +d = json.loads(sys.argv[1]) +agent = os.environ["AGENT"] +for r in d["agents"]: + if r["agent"] == agent and r.get("toolkit") is None: + worst = max(r["tools"], key=lambda t: t["bytes"], default=None) + print(r["prompt_bytes"], r["tool_bytes"], r["tool_count"], + worst["bytes"] if worst else 0, worst["name"] if worst else "-") + break +else: + sys.exit(f"agent '{agent}' is in {os.path.basename('prompt-budget.limits')} " + f"but was not measured — was it renamed or removed?") +PY + )"; then + status=1 + NEW_LINES+=("$raw") + continue + fi + + (( VERBOSE )) && echo "$agent prompt=$prompt_bytes/$max_prompt tools=$tool_bytes/$max_tools ($tool_count tools, worst $max_tool_name $max_tool_bytes B)" + + if (( prompt_bytes > max_prompt )); then + echo "::error::prompt budget REGRESSED: '$agent' renders $prompt_bytes B of" \ + "system prompt, limit is $max_prompt. Every turn pays this." >&2 + status=1 + elif (( max_prompt - prompt_bytes > SLACK )); then + echo "::error::prompt budget IMPROVED but was not ratcheted: '$agent' renders" \ + "$prompt_bytes B, limit is still $max_prompt. Lower it in this PR" \ + "(scripts/check-prompt-budget.sh --write) or the saving grows back." >&2 + status=1 + fi + + if (( tool_bytes > max_tools )); then + echo "::error::tool-schema budget REGRESSED: '$agent' advertises $tool_count" \ + "tools worth $tool_bytes B, limit is $max_tools. Consider deferring the" \ + "tool rather than trimming its description." >&2 + status=1 + elif (( max_tools - tool_bytes > SLACK )); then + echo "::error::tool-schema budget IMPROVED but was not ratcheted: '$agent' is" \ + "at $tool_bytes B against a limit of $max_tools. Lower it in this PR." >&2 + status=1 + fi + + NEW_LINES+=("$agent:$prompt_bytes:$tool_bytes") +done < "$LIMITS" + +# Any tool over the attention threshold must be recorded above. Checked after +# the loop so the message names every offender at once rather than failing on +# the first. +listed_tools="$(grep -oE '^tool:[^:]+' "$LIMITS" | cut -d: -f2 | sort -u || true)" +unlisted="$(LISTED="$listed_tools" THRESHOLD="$TOOL_ATTENTION_BYTES" \ + python3 "$TOOL_OVERSIZE" "$measured")" +while read -r name size; do + [[ -z "$name" ]] && continue + if (( WRITE )); then + NEW_LINES+=("tool:$name:$size") + echo "[prompt-budget] recording tool:$name:$size" >&2 + else + echo "::error::'$name' is $size B, over the $TOOL_ATTENTION_BYTES B attention" \ + "threshold, and is not in $LIMITS. Add 'tool:$name:$size' with a comment" \ + "saying why it needs that much, or trim it." >&2 + status=1 + fi +done <<< "$unlisted" + +if (( WRITE )); then + printf '%s\n' "${NEW_LINES[@]}" > "$LIMITS.tmp" + mv "$LIMITS.tmp" "$LIMITS" + echo "[prompt-budget] rewrote $LIMITS to measured values" >&2 + exit 0 +fi + +if (( status == 0 )); then + echo "[prompt-budget] OK — every agent is within its limit" >&2 +fi +exit "$status" diff --git a/scripts/prompt-budget.limits b/scripts/prompt-budget.limits new file mode 100644 index 0000000000..96f99ed163 --- /dev/null +++ b/scripts/prompt-budget.limits @@ -0,0 +1,316 @@ +# Fixed-prefix ratchet. `scripts/check-prompt-budget.sh` fails when an agent +# exceeds its limit here. +# +# Format: :: +# +# The two halves are separate on purpose. Prompt bytes are prose somebody wrote +# and can rewrite; tool bytes are schemas, and the fix for those is almost never +# "trim the description" — it is to defer the tool, collapse a family of verbs +# into one tool, or move the capability into a skill. +# +# Measured with `--hermetic`, which relocates BOTH the workspace and +# `config_path` into a temp dir. Overriding the workspace alone is not enough +# and the first version of this file got it wrong: credentials, auth profiles +# and integration toggles resolve against `config_path`'s parent, so a +# workspace-only override still read `~/.openhuman`. Roughly twenty +# backend-proxied tools (`google_places_*`, `stock_*`, `storage_*`, +# `twilio_call`, `composio_*`, `tinyfish_*`) all sit behind a single +# `if let Some(client) = integrations::build_client(..)`, so the whole block +# appeared or vanished depending on whether the developer happened to be signed +# in — a 12 KB swing that looked exactly like a code change. `openhuman/CLAUDE.md` +# documents this trap under "config_path is not cosmetic"; this file walked into +# it anyway, which is why the numbers below were re-recorded. +# +# Consequence worth knowing: this ratchet measures a SIGNED-OUT install, so it +# cannot see savings on tools that only register when a backend client exists. +# Deferring the five `stock_*` tools, for instance, is real but invisible here. +# Judge integration-tool work with `prompt-size` against a signed-in workspace +# and record the finding in a PR body, not in this file. +# +# Moving prose into a SKILL trades prompt bytes for tool bytes, and the trade is +# only worth recording if both halves are. `workflow_builder` gave up 21,445 B +# of standing prompt when its reference manual became the `flow-authoring` +# builtin skill, and took on 556 B for `read_workflow_resource` — the tool that +# reads a page. Net -20,889 B per turn. The other agents pay ~14 B for the +# mechanism (`skill_search` joined the withheld `skills` pack, which lengthened +# `load_skill`/`use_skill`'s summary) and the orchestrator 97 B for the sentence +# that tells it search exists. +# +# Two things that made the first attempt worse rather than better, both caught +# by numbers here rather than by review: +# +# * `read_workflow_resource` sat in the withheld `skills` pack, so naming it +# on the builder's belt advertised `load_skill` + `use_skill` (3,137 B) +# INSTEAD of the tool (556 B), and put a round trip in front of every read. +# Adding `workflow_builder` to that pack's `owners` fixed it and widened +# nothing — its belt is `Named`, so no other pack member is visible to it. +# * `skill_search` was registered outside any pack, costing 748 B on every +# wildcard agent to produce a skill id those agents could not act on, +# because `describe_workflow` and `run_skill` were still withheld. A +# doorway to a locked room. It belongs in the pack with them. +# +# A FALLING tool-byte count is not automatically good. Collapsing the memory, +# todo and cron families showed both failure modes in one afternoon: +# +# * Nine agents named the legacy tools in their `[tools] named` belts. Hiding +# those tools dropped the count on every one of them — and `scheduler_agent` +# was left with no scheduler tool at all. The ratchet reported that as an +# improvement. +# * Migrating a NARROW belt onto a collapsed tool makes it bigger and wider: +# `flow_memory_agent`'s three read-only memory tools (2,396 B) became the +# whole `memory` tool (3,788 B), handing an agent documented as read-only +# the `store` and `forget` actions its belt existed to withhold. +# +# So per-tool exposure now applies only to a wildcard belt (see +# `AgentBuilder::build`), and a drop here should be read alongside the agent's +# tool LIST, which `prompt-size --json` carries in full. Fewer bytes and fewer +# capabilities look identical in this file. +# +# The ratchet only goes DOWN. Lowering a number is the point; the check fails +# both on growth and on an un-ratcheted improvement, because a saving nobody +# writes back is a saving that grows back. Use +# `scripts/check-prompt-budget.sh --write` to record a deliberate reduction. +# +# `integrations_agent` is absent by construction: it is parameterised by a +# connected Composio toolkit and renders nothing on an empty workspace. +# +# A prompt REGRESSION can be the right trade, and one is recorded below. The +# orchestrator's prompt grew 168 B to say that specialists are now reached with +# `delegate_to { agent: "..." }` rather than as tools of their own. Without +# those two sentences the model calls `ask_docs` and gets "unknown tool" — the +# routing table in `## Delegation` names the specialists, and the names stopped +# being tool names. 168 B of prose bought 13,332 B of schema. The ratchet is +# there to make a trade like that visible and deliberate, not to forbid it. +# +# Measure with: openhuman-core agent prompt-size --workspace --json +# +# History +# 2026-08-31 Baseline, recorded the day the measurement first existed. +# Fleet total 1,073,644 B (~268k tokens of fixed prefix across 33 +# agents). Three things in this table are worth reading as bugs +# rather than as facts: +# +# * `morning_briefing`, `trigger_triage`, `summarizer` and +# `tools_agent` each advertise 151 tools / 104,567 B (~26k +# tokens). They declare no `[tools] named` belt, so they inherit +# the whole registry. `summarizer` exists to compress text and +# carries 151 tool schemas to do it. +# * `workflow_builder` renders 80,353 B (~20k tokens) of system +# prompt — more than twice the orchestrator's — because the flow +# DSL reference is inlined in its `prompt.md`. That body belongs +# in a SKILL.md. +# * the orchestrator's 45,199 B across 50 tools is dominated by a +# handful of schemas; `propose_workflow` alone was 7,568 B when +# this was first measured. +# +# Every one of those was invisible until this file existed. +# +# 2026-09-01 The archetype delegates collapsed into one `delegate_to` tool. +# Orchestrator tools 43,153 -> 29,821 B (46 -> 30 tools); fleet +# total 934,265 -> 921,351 B. +# +# The cause is worth recording because it was not a big schema, +# it was a small one repeated: `ArchetypeDelegationTool:: +# parameters_schema` is a `json!` literal that never reads +# `self`, so all 16 synthesised delegates carried a byte-identical +# delegation envelope. 17,746 B — 41% of the orchestrator's whole +# tool budget — was one object, sixteen times. The per-agent +# number could not show that: every individual tool sat under the +# 1,600 B attention threshold, so nothing in this file flagged +# them. **A family of near-identical schemas hides from both +# ratchets.** When the next one is looked for, group by schema +# body, not by size. +# +# Two regressions this introduced, both caught by measurement +# rather than review, and both now pinned by tests: +# +# * The first version made tools go UP, 43,153 -> 52,513. The +# members were marked `ToolExposure::Hidden`, but exposure is +# only applied to a WILDCARD belt, and the orchestrator's belt +# is `Named` — with every synthesised name force-inserted into +# it by `factory.rs` and again by `refresh_delegation_tools`. +# Both surfaces shipped. Hiding is now filtered at those two +# insertion points, which is the correct place: those names +# were never chosen by a human, so skipping one takes nothing +# an author asked for. +# * The collapse then silently RE-ADVERTISED seven routes the +# pack table withholds (`do_crypto`, `setup_mcp_server`, +# `use_mcp_server`, `setup_skills`, `run_skill`, +# `build_workflow`, `discover_workflows`). Each stopped being a +# tool of its own, so `strip_packed_from_visible` had nothing +# to remove, and it reappeared as a string inside another +# tool's schema where no visible-set subtraction reaches it. +# `toolpacks::is_withheld_from` now filters the enum. +# **A collapse must never widen what a pack narrowed** — check +# it whenever a surface moves from "a tool" to "a value". +# +# 2026-09-01 `named = []` now means zero tools. Fleet 921,327 -> 751,657 B. +# +# `summarizer` and `trigger_triage` each declare an empty named +# scope in their shipped `agent.toml`, and each was handed the +# ENTIRE registry — 109 tools, 82,986 B of schema — because an +# empty visible set is the harness's "no filter" sentinel. The +# declaration was not ignored, it was inverted. 165,972 B, 18% of +# the fleet's fixed prefix, on the two agents that asked for none. +# +# `trigger_triage`'s own comment says local 1B-class models are +# unreliable at nested tool calls, "so we keep the turn flat" — so +# this was not merely waste, it was working against the thing the +# author had written down. That is the general lesson: **this file +# cannot tell a large number from a wrong one.** Both agents sat +# at the top of the table from the day it was created, and the +# baseline note called them out as a bug in the wrong terms — +# "they declare no `[tools]` belt" — when in fact they declare an +# empty one, which is the opposite problem and a much cheaper fix. +# Read a definition, not just a row. +# +# `NO_TOOLS_SENTINEL` spells the empty belt so it survives a set +# whose empty state was already spoken for. It replaced a literal +# that existed twice, once with a comment saying it was a verbatim +# copy. +# +# Also trimmed 606 B from the orchestrator's "Running several +# workers at once" section, and fixed a bug it was hiding. Two of +# its four paragraphs restated `spawn_async_subagent`'s own +# description ("Fire-and-forget ... Returns immediately ... never +# use it for ... anything whose result must gate your final +# answer") in the prompt, where it is billed on every turn whether +# or not the tool is used. The schema is the better home: it is +# already paid for, and it cannot drift from the tool. +# +# The bug: the section told the model to call +# `spawn_async_subagent` with `blocking: true`. **That parameter +# does not exist on that tool** — its properties are agent_id, +# context, fresh, model, prompt, task_key, task_title, toolkit. +# `blocking` is on `delegate_to`. So the one hard rule about +# result-gating pointed at an impossible call, and the same +# section opened by asserting the tool is "always async ... you do +# not wait for it", contradicting its own closing paragraph. +# +# Worth noting how it was found: not by reading the prompt, but by +# reading the prompt NEXT TO the tool schemas, which is what +# `dump-prompt --wire` exists to make easy. A contradiction between +# a prompt and a schema is invisible while the two live in +# different files. +# +# The six bullets stay. They are identity and recovery rules that +# appear in no schema — track by `subagent_session_id` because +# `agentId` is only the worker type; `[active_subagents]` over +# recollection; `list_subagents` as the recovery move; and +# `continue_subagent` for an `awaiting_user` worker, which is the +# prose half of the #4291 infinite-re-delegation fix that +# `toolpacks::registry` cites as its reason for never packing the +# fleet tools. Deleting the section wholesale would have removed +# a known bug's fix along with the redundancy. + +morning_briefing:14279:82986 +trigger_triage:9214:0 +workflow_builder:46405:30259 +summarizer:8783:0 +tools_agent:8260:82986 +orchestrator:33629:29821 +code_executor:13327:12338 +crypto_agent:12049:12357 +task_manager_agent:7104:15601 +planner:9927:5664 +skill_creator:7311:12411 +flow_discovery:10111:9503 +profile_memory_agent:7202:11815 +settings_agent:7065:11220 +context_scout:10427:7652 +skill_executor:9613:8090 +scheduler_agent:9635:7008 +agent_memory:9761:6698 +mcp_setup:10368:5078 +skill_setup:7272:7973 +trigger_reactor:7966:6423 +mcp_agent:8927:4472 +flow_memory_agent:9026:3809 +tool_maker:6273:6087 +presentation_agent:6547:5678 +video_agent:6973:2266 +help:8469:2365 +image_agent:7017:2266 +goals_agent:6725:3124 +vision_agent:6885:2266 +archivist:5985:3133 +researcher:6855:2229 +critic:6222:1855 + +# ── Per-tool schema ratchet ────────────────────────────────────────────── +# +# One line per tool whose schema is over the attention threshold (1,600 B, +# ~400 tokens). Being here is not a fault; being here *unexamined* is. Each +# entry should be justified by the comment above it or be trimmed. +# +# Why the big ones are big, as of 2026-08-31: +# +# memory (3,788) / cron (3,228) / todo (1,831) +# Collapsed families. `memory` replaced eleven tools worth 7,879 B and +# `cron` six worth 3,938 B, so each is large *because* the surface got +# smaller. A union schema costs more than any one member and less than +# all of them. Do not "fix" these by splitting them back up. +# +# spawn_subagent (3,554) / spawn_async_subagent (1,965) / +# spawn_parallel_agents (1,851) +# The delegation surface: three tools describing overlapping arguments. +# A collapse candidate on the same argument as memory/cron/todo, and the +# next obvious one — 7,370 B between them. +# +# memory_tree (3,008) / cron_add (3,003) +# `memory_tree` dispatches eight tree operations on `mode` and is the +# one memory tool deliberately left out of the `memory` collapse. +# `cron_add` is a member of the collapsed `cron` tool and no longer +# reaches the wire itself, but is measured here because some agent still +# carries it on a hand-written belt. +# +# propose_workflow (3,170) +# Was 7,415 until its node-kind reference stopped being hand-written and +# started being generated from `node_contracts.rs` +# (`render_node_kinds_required`). What remains is this tool's own +# contract plus 401 B of generated kinds. +# +# edit_workflow (2,721) / save_workflow (1,957) / suggest_workflows (2,445) +# The rest of the flow-authoring belt. Each carries its own prose copy of +# graph-shape rules; the same generate-don't-hand-write treatment that +# shrank `propose_workflow` applies and has not been done. +# +# generate_presentation (2,662) +# A deck spec: slides, layouts, per-layout fields. Genuinely wide. +# +# delegate_to (5,302) +# The collapsed archetype delegation tool. 1,404 B of that is the shared +# envelope and the `agent` enum; the other 3,898 B is 17 specialists' +# `when_to_use` blurbs, which are the routing information itself — this +# tool is how the orchestrator picks a specialist at all. +# +# It is large because 16 tools worth 17,746 B became one. Do not "fix" it +# by splitting it back up, and do not trim the blurbs mechanically: at +# least three carry NEGATIVE boundaries that prevent mis-routing +# (`scheduler_agent`: "reading live calendar events ... belongs to the +# calendar/email integration"), and a first-sentence truncation drops +# exactly those. The real remaining duplication is with the prompt's own +# `## Delegation` routing table, which names the same specialists again +# in 7,912 B — deduplicating the two is the next saving here, and it is +# an editorial change, not a mechanical one. +# +# search_tool_catalog (1,695) / use_skill (1,620) +# Discovery tools. Both are the recovery path for a withheld surface, so +# their descriptions carry the "here is what you can still reach" copy. +# +tool:memory:3788 +tool:delegate_to:5302 +tool:spawn_subagent:3554 +tool:cron:3228 +tool:propose_workflow:3170 +tool:memory_tree:3008 +tool:cron_add:3003 +tool:edit_workflow:2721 +tool:generate_presentation:2662 +tool:suggest_workflows:2445 +tool:spawn_async_subagent:1965 +tool:save_workflow:1957 +tool:spawn_parallel_agents:1851 +tool:todo:1831 +tool:search_tool_catalog:1695 +tool:use_skill:1627 diff --git a/src/core/agent_cli.rs b/src/core/agent_cli.rs index c07c39035b..a02d51180c 100644 --- a/src/core/agent_cli.rs +++ b/src/core/agent_cli.rs @@ -6,9 +6,10 @@ //! agent definitions / tool registry and printing something. //! //! Usage: -//! openhuman agent dump-prompt --agent [--toolkit ] [--workspace ] [--json] [--with-tools] [-v] +//! openhuman agent dump-prompt --agent [--toolkit ] [--workspace ] [--json] [--with-tools] [--wire] [-v] //! (--toolkit is REQUIRED when --agent is `integrations_agent`.) //! openhuman agent dump-all --out [--workspace ] [--model ] [-v] +//! openhuman agent prompt-size [--agent ] [--toolkit ] [--workspace ] [--json] [-v] //! openhuman agent list [--json] [-v] //! //! `dump-prompt` is the main tool: it renders the exact system prompt the @@ -23,6 +24,7 @@ use anyhow::{anyhow, Result}; use std::path::PathBuf; +use crate::openhuman::agent::debug::prompt_size::{render_text, PromptSizeReport}; use crate::openhuman::agent::debug::{ dump_agent_prompt, dump_all_agent_prompts, write_prompt_dumps, DumpPromptOptions, DumpedPrompt, }; @@ -38,6 +40,7 @@ pub fn run_agent_command(args: &[String]) -> Result<()> { match args[0].as_str() { "dump-prompt" => run_dump_prompt(&args[1..]), "dump-all" => run_dump_all(&args[1..]), + "prompt-size" => run_prompt_size(&args[1..]), "list" => run_list(&args[1..]), other => Err(anyhow!( "unknown agent subcommand '{other}'. Run `openhuman agent --help`." @@ -45,6 +48,221 @@ pub fn run_agent_command(args: &[String]) -> Result<()> { } } +// --------------------------------------------------------------------------- +// prompt-size +// --------------------------------------------------------------------------- + +/// How many rows the human-readable section / tool tables print. +/// +/// `--json` always carries every row; these caps only keep the terminal +/// output readable. The orchestrator advertises well over a hundred tools and +/// a full dump scrolls the interesting rows off the screen, which defeats the +/// point of a diagnostic. +const PROMPT_SIZE_SECTION_ROWS: usize = 15; +const PROMPT_SIZE_TOOL_ROWS: usize = 20; + +/// Where `--hermetic` puts the config file, relative to the workspace's parent. +/// +/// Mirrors the layout `Config::load_or_init` produces and `Harness` reproduces: +/// `config.toml` beside the `workspace` directory, not inside it. +const HERMETIC_CONFIG_FILENAME: &str = "config.toml"; + +struct PromptSizeFlags { + /// `None` means "every registered agent" — the fleet-wide view the ratchet + /// consumes. + agent: Option, + toolkit: Option, + workspace: Option, + model: Option, + json: bool, + verbose: bool, + /// Also relocate `config_path` beside the workspace, so credentials and + /// integration toggles come from the temp dir rather than `~/.openhuman`. + hermetic: bool, +} + +fn parse_prompt_size_flags(args: &[String]) -> Result { + let mut agent: Option = None; + let mut toolkit: Option = None; + let mut workspace: Option = None; + let mut model: Option = None; + let mut json = false; + let mut verbose = false; + let mut hermetic = false; + let mut i = 0usize; + while i < args.len() { + match args[i].as_str() { + "--hermetic" => { + hermetic = true; + i += 1; + } + "--agent" | "-a" => { + agent = Some( + args.get(i + 1) + .ok_or_else(|| anyhow!("missing value for --agent"))? + .clone(), + ); + i += 2; + } + "--toolkit" | "-t" => { + toolkit = Some( + args.get(i + 1) + .ok_or_else(|| anyhow!("missing value for --toolkit"))? + .clone(), + ); + i += 2; + } + "--workspace" | "-w" => { + workspace = Some(PathBuf::from( + args.get(i + 1) + .ok_or_else(|| anyhow!("missing value for --workspace"))?, + )); + i += 2; + } + "--model" | "-m" => { + model = Some( + args.get(i + 1) + .ok_or_else(|| anyhow!("missing value for --model"))? + .clone(), + ); + i += 2; + } + "--json" => { + json = true; + i += 1; + } + "-v" | "--verbose" => { + verbose = true; + i += 1; + } + "-h" | "--help" => { + print_prompt_size_help(); + std::process::exit(0); + } + other => return Err(anyhow!("unknown prompt-size arg: {other}")), + } + } + Ok(PromptSizeFlags { + agent, + toolkit, + workspace, + model, + json, + verbose, + hermetic, + }) +} + +/// `openhuman agent prompt-size` — report where an agent's fixed per-turn +/// budget goes. +/// +/// With `--agent`, renders one agent. Without it, renders every registered +/// agent through the same `dump_all_agent_prompts` path `dump-all` uses, so +/// the fleet view and the per-agent view cannot disagree. +fn run_prompt_size(args: &[String]) -> Result<()> { + let flags = parse_prompt_size_flags(args)?; + init_quiet_logging(flags.verbose); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(crate::core::runtime::AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(crate::core::runtime::MAX_BLOCKING_THREADS) + .build()?; + + // `--hermetic` without `--workspace` would silently measure the real + // install, which is the failure this flag exists to prevent — so refuse + // rather than guess. + let config_path = if flags.hermetic { + let Some(workspace) = flags.workspace.as_ref() else { + return Err(anyhow!("--hermetic requires --workspace ")); + }; + let parent = workspace.parent().unwrap_or(workspace.as_path()); + Some(parent.join(HERMETIC_CONFIG_FILENAME)) + } else { + None + }; + + let reports: Vec = match &flags.agent { + Some(agent_id) => { + let mut options = DumpPromptOptions::new(agent_id.clone()); + options.toolkit = flags.toolkit.clone(); + options.workspace_dir_override = flags.workspace.clone(); + options.config_path_override = config_path.clone(); + options.model_override = flags.model.clone(); + vec![rt.block_on(PromptSizeReport::build(options))?] + } + None => { + let dumps: Vec = rt.block_on(async { + dump_all_agent_prompts( + flags.workspace.clone(), + config_path.clone(), + flags.model.clone(), + ) + .await + })?; + dumps.iter().map(PromptSizeReport::from_dump).collect() + } + }; + + if flags.json { + // A bare array for a single agent would force every consumer to + // special-case arity. The ratchet reads `agents`, always a list. + let total: usize = reports.iter().map(|r| r.fixed_prefix_bytes).sum(); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "agents": reports, + "fixed_prefix_bytes_total": total, + }))? + ); + return Ok(()); + } + + for (idx, report) in reports.iter().enumerate() { + if idx > 0 { + println!("\n{}\n", "-".repeat(72)); + } + print!( + "{}", + render_text(report, PROMPT_SIZE_SECTION_ROWS, PROMPT_SIZE_TOOL_ROWS) + ); + } + if reports.len() > 1 { + let total: usize = reports.iter().map(|r| r.fixed_prefix_bytes).sum(); + println!("\n{}\n", "=".repeat(72)); + println!( + "{} agents, {} B of fixed prefix in total", + reports.len(), + total + ); + } + Ok(()) +} + +fn print_prompt_size_help() { + println!("openhuman agent prompt-size — where an agent's fixed per-turn budget goes"); + println!(); + println!("Reports the system prompt AND the advertised tool schemas, which ride"); + println!("alongside it in every request and are typically the larger half."); + println!(); + println!("Usage:"); + println!(" openhuman agent prompt-size [--agent ] [options]"); + println!(); + println!("Options:"); + println!(" --agent, -a One agent. Omit to report every registered agent."); + println!(" --toolkit, -t REQUIRED when `--agent integrations_agent`."); + println!(" --workspace, -w

Workspace to resolve identity/memory files against."); + println!(" --model, -m Override the resolved model name."); + println!(" --hermetic Also resolve config + credentials from the --workspace"); + println!(" parent, not ~/.openhuman. REQUIRED for a reproducible"); + println!(" number: ~20 backend-proxied integration tools appear or"); + println!(" vanish with whether you happen to be signed in."); + println!(" --json Full machine-readable breakdown (every row)."); + println!(" -v, --verbose Restore normal logging."); + println!(); + println!("Bytes are the unit of record; the `~tok` column is an estimate for reading."); +} + // --------------------------------------------------------------------------- // dump-all // --------------------------------------------------------------------------- @@ -127,7 +345,7 @@ fn run_dump_all(args: &[String]) -> Result<()> { .build()?; log::debug!("[agent-cli] run_dump_all: calling dump_all_agent_prompts"); let dumps = rt.block_on(async { - dump_all_agent_prompts(flags.workspace.clone(), flags.model.clone()).await + dump_all_agent_prompts(flags.workspace.clone(), None, flags.model.clone()).await })?; log::debug!( "[agent-cli] run_dump_all: dump_all_agent_prompts returned {} prompt(s)", @@ -153,6 +371,7 @@ struct DumpFlags { model: Option, json: bool, with_tools: bool, + wire: bool, verbose: bool, } @@ -164,6 +383,7 @@ fn parse_dump_flags(args: &[String]) -> Result { model: None, json: false, with_tools: false, + wire: false, verbose: false, }; let mut i = 0usize; @@ -208,6 +428,10 @@ fn parse_dump_flags(args: &[String]) -> Result { out.with_tools = true; i += 1; } + "--wire" => { + out.wire = true; + i += 1; + } "-v" | "--verbose" => { out.verbose = true; i += 1; @@ -250,6 +474,11 @@ fn run_dump_prompt(args: &[String]) -> Result<()> { agent_id: agent, toolkit: flags.toolkit.clone(), workspace_dir_override: flags.workspace.clone(), + // `dump-prompt` deliberately keeps reading the real install: its job is + // to show what the signed-in user's agent actually receives, including + // their connected integrations. `prompt-size --hermetic` is the one + // that needs reproducibility. + config_path_override: None, model_override: flags.model.clone(), }; @@ -267,7 +496,16 @@ fn run_dump_prompt(args: &[String]) -> Result<()> { dumped.text.len() ); - if flags.json { + if flags.wire { + // Everything on stdout, deliberately: this artefact is one document + // and splitting the header onto stderr the way `print_human` does + // would make `> turn.txt` drop the byte counts that give the payload + // its meaning. + print!( + "{}", + crate::openhuman::agent::debug::render_wire_dump(&dumped) + ); + } else if flags.json { print_json(&dumped, flags.with_tools)?; } else { print_human(&dumped, flags.with_tools); @@ -470,8 +708,9 @@ fn print_agent_help() { println!(); println!("Usage:"); println!(" openhuman agent list [--workspace ] [--json]"); - println!(" openhuman agent dump-prompt --agent [--workspace ] [--model ] [--with-tools] [--json] [-v]"); + println!(" openhuman agent dump-prompt --agent [--workspace ] [--model ] [--with-tools] [--wire] [--json] [-v]"); println!(" openhuman agent dump-all --out

[--workspace ] [--model ] [-v]"); + println!(" openhuman agent prompt-size [--agent ] [--toolkit ] [--workspace ] [--json] [-v]"); println!(); println!("Run `openhuman agent --help` for details."); } @@ -495,7 +734,14 @@ fn print_dump_prompt_help() { println!(" Config::workspace_dir / ~/.openhuman/workspace)."); println!(" --model, -m Override the resolved model name (affects only the"); println!(" `## Runtime` section)."); - println!(" --with-tools Also print the full list of tool names the agent sees."); + println!( + " --with-tools Also print the full list of tool names the agent sees. + --wire Print the ENTIRE fixed prefix exactly as the model + receives it: the system prompt verbatim, then every + advertised tool schema minified the way it is sent, + with byte counts for each half. This is the whole + per-turn cost in one document." + ); println!(" --json Emit a machine-readable JSON object on stdout."); println!(" -v, --verbose Enable debug logging on stderr."); println!(); diff --git a/src/openhuman/agent/context/manager.rs b/src/openhuman/agent/context/manager.rs index 17cd59e7bd..47eace6f96 100644 --- a/src/openhuman/agent/context/manager.rs +++ b/src/openhuman/agent/context/manager.rs @@ -216,13 +216,28 @@ impl ContextManager { /// Assemble the opening system prompt for a session using the /// manager's default [`SystemPromptBuilder`]. /// - /// The returned bytes are the full system prompt, intended to be - /// built once at session start and reused verbatim on every turn — - /// the inference backend's prefix cache picks up the stable prefix - /// automatically, so no boundary marker is emitted. + /// The returned bytes are the full system prompt, intended to be built + /// once at session start and reused verbatim on every turn. Callers that + /// can carry cache breakpoints to the provider should use + /// [`Self::build_system_prompt_tiered`] instead; this wrapper exists for + /// the call sites that only want the bytes. pub fn build_system_prompt(&self, ctx: &PromptContext<'_>) -> Result { - let prompt = self.default_prompt_builder.build(ctx)?; - Ok(prompt) + Ok(self.build_system_prompt_tiered(ctx)?.text) + } + + /// Assemble the system prompt and report its cache-tier boundaries. + /// + /// The doc comment above used to end "the inference backend's prefix cache + /// picks up the stable prefix automatically, so no boundary marker is + /// emitted." That is true of backends with automatic longest-prefix caching + /// and false of Anthropic, which caches nothing without an explicit + /// breakpoint — so on Anthropic-family models this codebase re-paid full + /// input price on a ~37k-token prefix, every turn, silently. + pub fn build_system_prompt_tiered( + &self, + ctx: &PromptContext<'_>, + ) -> Result { + self.default_prompt_builder.build_tiered(ctx) } /// Assemble the system prompt via a caller-supplied builder. diff --git a/src/openhuman/agent/debug/dump_writer.rs b/src/openhuman/agent/debug/dump_writer.rs index 4a5c9fe144..31c151ff4d 100644 --- a/src/openhuman/agent/debug/dump_writer.rs +++ b/src/openhuman/agent/debug/dump_writer.rs @@ -60,6 +60,15 @@ pub fn write_prompt_dumps(dir: &Path, dumps: &[DumpedPrompt]) -> Result, /// Optional override for the workspace directory. pub workspace_dir_override: Option, + pub config_path_override: Option, /// Optional override for the resolved model name. pub model_override: Option, } @@ -67,6 +71,7 @@ impl DumpPromptOptions { agent_id: agent_id.into(), toolkit: None, workspace_dir_override: None, + config_path_override: None, model_override: None, } } @@ -127,6 +132,7 @@ fn tool_specs_of<'a, T: std::ops::Deref Result { let config = load_dump_config( options.workspace_dir_override.clone(), + options.config_path_override.clone(), options.model_override.clone(), ) .await?; @@ -165,9 +171,11 @@ pub async fn dump_agent_prompt(options: DumpPromptOptions) -> Result, + config_path_override: Option, model_override: Option, ) -> Result> { - let config = load_dump_config(workspace_dir_override, model_override).await?; + let config = + load_dump_config(workspace_dir_override, config_path_override, model_override).await?; AgentDefinitionRegistry::init_global(&config.workspace_dir) .context("initialising AgentDefinitionRegistry for prompt dump")?; @@ -215,11 +223,21 @@ pub async fn dump_all_agent_prompts( async fn load_dump_config( workspace_dir_override: Option, + config_path_override: Option, model_override: Option, ) -> Result { - let mut config = Config::load_or_init() - .await - .context("loading Config for prompt dump")?; + let mut config = if let Some(path) = config_path_override { + let workspace = workspace_dir_override + .as_deref() + .ok_or_else(|| anyhow!("config path override requires workspace override"))?; + Config::load_from_config_path(&path, workspace) + .await + .context("loading hermetic Config for prompt dump")? + } else { + Config::load_or_init() + .await + .context("loading Config for prompt dump")? + }; config.apply_env_overrides(); if let Some(override_dir) = workspace_dir_override { config.workspace_dir = override_dir; diff --git a/src/openhuman/agent/debug/prompt_size.rs b/src/openhuman/agent/debug/prompt_size.rs new file mode 100644 index 0000000000..17481c1c5d --- /dev/null +++ b/src/openhuman/agent/debug/prompt_size.rs @@ -0,0 +1,289 @@ +//! `openhuman agent prompt-size` — where an agent's fixed per-turn budget goes. +//! +//! A turn's fixed cost is the system prompt **plus** the tool schemas that ride +//! alongside it in every request, and on this codebase the second is roughly +//! three times the first: the orchestrator renders ~37 KB of prompt text next +//! to ~112 KB of advertised tool schema. Every prior discussion of prompt size +//! here has been about the prose, because the prose is the half that was +//! visible. This report exists to put both halves on one screen. +//! +//! Modelled on Hermes' `hermes prompt-size` (`hermes_cli/prompt_size.py`), +//! which exists for the same reason and states it plainly: *"Lets users see +//! where their fixed prompt budget goes … without parsing a saved session JSON +//! by hand."* +//! +//! # What is measured +//! +//! Everything comes from [`super::dump_agent_prompt`], which builds a real +//! agent through `Agent::from_config_for_agent` and renders the turn-1 prompt. +//! No numbers are re-derived from a second code path, so the report cannot +//! drift from the dump. +//! +//! # Bytes, not tokens, are the unit of record +//! +//! Token counts depend on the tokenizer, which depends on the model, which is +//! a per-session choice. Bytes are exact and reproducible on any host, so the +//! CI ratchet (`scripts/check-prompt-budget.sh`) works on bytes and this report +//! prints an estimate alongside them purely as a reading aid. Do not tighten +//! [`EST_BYTES_PER_TOKEN`] into a claim of accuracy — it is a divisor, not a +//! tokenizer. + +use anyhow::Result; + +use super::{dump_agent_prompt, DumpPromptOptions, DumpedPrompt}; + +/// Rough bytes-per-token for English prose and JSON on a BPE tokenizer. +/// +/// Used only to render a human-readable `~N tok` column. The ratchet compares +/// bytes. +pub const EST_BYTES_PER_TOKEN: usize = 4; + +/// One markdown section of the rendered system prompt. +/// +/// Sections are derived by scanning `#` / `##` / `###` headings in the rendered +/// text rather than by asking the builder, because the builder joins its +/// [`crate::openhuman::agent::prompts::PromptSection`]s into one string and a +/// single section routinely emits several headings (the orchestrator archetype +/// alone contributes a dozen). Heading-derived attribution is what a reader +/// editing a prompt actually wants: it points at the block of text to cut. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SectionSize { + /// The heading line, verbatim, including its leading `#`s. + pub heading: String, + /// Bytes from this heading up to the next heading of any level. + pub bytes: usize, +} + +/// One advertised tool's JSON schema cost. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ToolSize { + pub name: String, + /// Bytes of the compact `{name, description, parameters}` JSON — the shape + /// the provider receives. + pub bytes: usize, + /// Bytes attributable to `parameters` alone, so a reader can tell an + /// over-described tool from an over-parameterised one. + pub parameters_bytes: usize, +} + +/// The full breakdown for one agent. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PromptSizeReport { + pub agent: String, + pub toolkit: Option, + pub model: String, + /// Rendered system-prompt bytes. + pub prompt_bytes: usize, + /// Advertised tool-schema bytes. + pub tool_bytes: usize, + /// `prompt_bytes + tool_bytes` — the fixed cost of every turn. + pub fixed_prefix_bytes: usize, + /// Number of tools whose schemas reach the provider. This is the + /// *advertised* set, already narrowed by the agent's `ToolScope` belt and + /// by toolpack withholding. + pub tool_count: usize, + pub sections: Vec, + pub tools: Vec, +} + +impl PromptSizeReport { + /// Build the report for one agent by rendering its real turn-1 prompt. + pub async fn build(options: DumpPromptOptions) -> Result { + let dumped = dump_agent_prompt(options).await?; + Ok(Self::from_dump(&dumped)) + } + + /// Derive the report from an already-rendered dump. + /// + /// Split out from [`Self::build`] so `dump-all` can report every agent + /// without paying a second render per agent — each render fetches live + /// Composio connections and walks the memory tree. + pub fn from_dump(dumped: &DumpedPrompt) -> Self { + let sections = split_sections(&dumped.text); + let mut tools: Vec = dumped + .tool_specs + .iter() + .map(|spec| { + let name = spec + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let parameters_bytes = spec + .get("parameters") + .map(|v| serde_json::to_string(v).map(|s| s.len()).unwrap_or(0)) + .unwrap_or(0); + ToolSize { + name, + bytes: serde_json::to_string(spec).map(|s| s.len()).unwrap_or(0), + parameters_bytes, + } + }) + .collect(); + // Descending by cost: the point of the table is to name what to cut + // first, and registration order carries no information a reader wants. + tools.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name))); + + let prompt_bytes = dumped.text.len(); + let tool_bytes = tools.iter().map(|t| t.bytes).sum(); + Self { + agent: dumped.agent_id.clone(), + toolkit: dumped.toolkit.clone(), + model: dumped.model.clone(), + prompt_bytes, + tool_bytes, + fixed_prefix_bytes: prompt_bytes + tool_bytes, + tool_count: tools.len(), + sections, + tools, + } + } + + /// Estimated tokens for the whole fixed prefix. Reading aid only. + pub fn est_tokens(&self) -> usize { + self.fixed_prefix_bytes / EST_BYTES_PER_TOKEN + } +} + +/// Attribute every byte of `text` to the heading it falls under. +/// +/// Bytes before the first heading are attributed to a synthetic `(preamble)` +/// entry rather than dropped — silently losing them would make the section +/// table fail to sum to `prompt_bytes`, which is exactly the kind of quiet +/// inaccuracy that makes a budget tool untrustworthy. +fn split_sections(text: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut current = SectionSize { + heading: "(preamble)".to_string(), + bytes: 0, + }; + for line in text.split_inclusive('\n') { + if is_heading(line) { + if current.bytes > 0 || current.heading != "(preamble)" { + out.push(std::mem::replace( + &mut current, + SectionSize { + heading: line.trim_end().to_string(), + bytes: line.len(), + }, + )); + } else { + current = SectionSize { + heading: line.trim_end().to_string(), + bytes: line.len(), + }; + } + continue; + } + current.bytes += line.len(); + } + out.push(current); + out +} + +/// A markdown ATX heading: one to three `#` followed by a space. +/// +/// Deliberately does not match `####` and deeper — at that depth the blocks are +/// small enough that the table becomes noise rather than signal — and does not +/// match a `#` inside a fenced code block, because the prompts do not contain +/// fenced blocks with leading-`#` lines and carrying a fence state machine here +/// would be more machinery than the report is worth. If that changes, this is +/// the function to fix. +fn is_heading(line: &str) -> bool { + let trimmed = line.trim_start_matches('\u{feff}'); + matches!( + ( + trimmed.starts_with("# "), + trimmed.starts_with("## "), + trimmed.starts_with("### ") + ), + (true, _, _) | (_, true, _) | (_, _, true) + ) +} + +/// Render the human-readable report. +/// +/// `section_limit` / `tool_limit` cap the two tables; `--json` always carries +/// every row. +pub fn render_text(report: &PromptSizeReport, section_limit: usize, tool_limit: usize) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + let est = |b: usize| b / EST_BYTES_PER_TOKEN; + + let label = match &report.toolkit { + Some(t) => format!("{}@{}", report.agent, t), + None => report.agent.clone(), + }; + let _ = writeln!(out, "agent: {label}"); + let _ = writeln!(out, "model: {}", report.model); + let _ = writeln!(out); + let _ = writeln!( + out, + "fixed prefix: {:>9} B ~{:>7} tok", + report.fixed_prefix_bytes, + est(report.fixed_prefix_bytes) + ); + let _ = writeln!( + out, + " system prompt {:>9} B ~{:>7} tok", + report.prompt_bytes, + est(report.prompt_bytes) + ); + let _ = writeln!( + out, + " tool schemas {:>9} B ~{:>7} tok ({} advertised tools)", + report.tool_bytes, + est(report.tool_bytes), + report.tool_count + ); + + let mut sections: Vec<&SectionSize> = report.sections.iter().collect(); + sections.sort_by_key(|s| std::cmp::Reverse(s.bytes)); + let _ = writeln!(out, "\nPrompt sections by size"); + for s in sections.iter().take(section_limit) { + let _ = writeln!( + out, + " {:>7} B ~{:>6} tok {}", + s.bytes, + est(s.bytes), + s.heading + ); + } + if sections.len() > section_limit { + let rest: usize = sections[section_limit..].iter().map(|s| s.bytes).sum(); + let _ = writeln!( + out, + " {:>7} B ~{:>6} tok … {} more sections", + rest, + est(rest), + sections.len() - section_limit + ); + } + + let _ = writeln!(out, "\nTool schemas by size"); + for t in report.tools.iter().take(tool_limit) { + let _ = writeln!( + out, + " {:>7} B ~{:>6} tok {:<34} (params {} B)", + t.bytes, + est(t.bytes), + t.name, + t.parameters_bytes + ); + } + if report.tools.len() > tool_limit { + let rest: usize = report.tools[tool_limit..].iter().map(|t| t.bytes).sum(); + let _ = writeln!( + out, + " {:>7} B ~{:>6} tok … {} more tools", + rest, + est(rest), + report.tools.len() - tool_limit + ); + } + out +} + +#[cfg(test)] +#[path = "prompt_size_tests.rs"] +mod tests; diff --git a/src/openhuman/agent/debug/prompt_size_tests.rs b/src/openhuman/agent/debug/prompt_size_tests.rs new file mode 100644 index 0000000000..8c571187b3 --- /dev/null +++ b/src/openhuman/agent/debug/prompt_size_tests.rs @@ -0,0 +1,73 @@ +use super::*; + +#[test] +fn sections_sum_to_the_whole_prompt() { + let text = "preamble line\n# Title\nbody\n## Sub\nmore body\n### Deep\ntail\n"; + let sections = split_sections(text); + let total: usize = sections.iter().map(|s| s.bytes).sum(); + assert_eq!( + total, + text.len(), + "section bytes must account for every byte of the prompt; \ + a table that does not sum is worse than no table" + ); +} + +#[test] +fn preamble_is_kept_when_text_starts_before_the_first_heading() { + let sections = split_sections("loose text\n# Title\nbody\n"); + assert_eq!(sections[0].heading, "(preamble)"); + assert_eq!(sections[0].bytes, "loose text\n".len()); +} + +#[test] +fn no_preamble_entry_when_the_prompt_opens_on_a_heading() { + let sections = split_sections("# Title\nbody\n"); + assert_eq!(sections.len(), 1); + assert_eq!(sections[0].heading, "# Title"); +} + +#[test] +fn deeper_headings_do_not_split() { + // `####` is body text as far as this report is concerned. + let sections = split_sections("## Sub\na\n#### Deeper\nb\n"); + assert_eq!(sections.len(), 1); +} + +#[test] +fn a_bare_hash_is_not_a_heading() { + // No trailing space: a shell comment in an example block, not a section. + assert!(!is_heading("#!/usr/bin/env bash\n")); + assert!(!is_heading("#hashtag\n")); + assert!(is_heading("## Real\n")); +} + +#[test] +fn tools_are_ranked_by_cost_not_registration_order() { + let dumped = DumpedPrompt { + agent_id: "t".into(), + toolkit: None, + mode: "session", + model: "m".into(), + workspace_dir: std::path::PathBuf::from("/tmp"), + text: "# A\nbody\n".into(), + tool_names: vec!["small".into(), "big".into()], + skill_tool_count: 0, + tool_specs: vec![ + serde_json::json!({"name": "small", "description": "s", "parameters": {}}), + serde_json::json!({ + "name": "big", + "description": "a much longer description than the other one", + "parameters": {"type": "object", "properties": {"a": {"type": "string"}}} + }), + ], + }; + let report = PromptSizeReport::from_dump(&dumped); + assert_eq!(report.tools[0].name, "big"); + assert_eq!(report.tool_count, 2); + assert_eq!( + report.fixed_prefix_bytes, + report.prompt_bytes + report.tool_bytes + ); + assert!(report.tools[1].parameters_bytes > 0, "`{{}}` is two bytes"); +} diff --git a/src/openhuman/agent/debug/wire.rs b/src/openhuman/agent/debug/wire.rs new file mode 100644 index 0000000000..8d5ca20e0a --- /dev/null +++ b/src/openhuman/agent/debug/wire.rs @@ -0,0 +1,184 @@ +//! One artefact holding everything a turn ships before the user has spoken. +//! +//! `dump-prompt` wrote the system prompt and `dump-all` wrote the tool schemas +//! into a *sibling* `.tools.json`, pretty-printed. Both halves existed, and +//! neither was what the model receives: the prompt alone is the smaller half of +//! the fixed cost, and pretty-printing inflates the schemas by roughly a third +//! in indentation the model never sees. Reading the two files together and +//! mentally minifying one of them is not a thing anyone does, so in practice +//! the schema half went unlooked-at — which is how sixteen copies of one +//! delegation envelope sat in the orchestrator's budget unnoticed. +//! +//! This renders both halves, in the form they are actually sent, with the +//! byte counts beside them. +//! +//! # What "as sent" means here, precisely +//! +//! Two things are byte-exact: the system prompt text, and each tool schema +//! **minified** with `serde_json::to_string` — the same call +//! `toolpacks::tools::render_pack` uses, for the same reason. +//! +//! What this deliberately does *not* do is fabricate an HTTP body. The +//! surrounding envelope (message array, provider-specific `tools` vs +//! `functions` key, sampling parameters) differs per provider and is built +//! elsewhere; inventing one here would produce a file that *looks* like a +//! captured request and is not. The two payload halves are labelled and exact; +//! the framing around them is presented as framing. +//! +//! # Dialects +//! +//! Under `ToolCallFormat::Native` the schemas ride beside the prompt as +//! structured JSON, which is the shape below. Under `PFormat` / `Json` the same +//! tools are rendered *into* the prompt text as a catalogue, so they are +//! already counted in the prompt half and the array below is what the harness +//! would advertise natively. Either way the total is the total, which is why +//! the header reports it as one number. + +use std::fmt::Write as _; + +use super::DumpedPrompt; + +/// A rough token estimate. Bytes ÷ 4 — the same ratio `prompt-size` uses, kept +/// identical so the two tools never disagree about the same prompt. +fn est_tokens(bytes: usize) -> usize { + bytes / 4 +} + +fn thousands(n: usize) -> String { + let s = n.to_string(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, c) in s.chars().enumerate() { + if i > 0 && (s.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(c); + } + out +} + +/// The exact bytes of one tool's schema as it goes on the wire. +/// +/// Minified, because that is what is sent. A pretty-printed schema is a +/// different — larger — number, and reporting it would overstate every tool. +pub fn tool_schema_bytes(spec: &serde_json::Value) -> usize { + serde_json::to_string(spec).map(|s| s.len()).unwrap_or(0) +} + +/// Total advertised tool-schema bytes for a dump. +pub fn total_tool_bytes(dumped: &DumpedPrompt) -> usize { + dumped.tool_specs.iter().map(tool_schema_bytes).sum() +} + +/// Render the full fixed prefix: header, system prompt, tool schemas. +/// +/// The output is plain text and deliberately greppable — `dump-all` writes it +/// to `{stem}.wire.txt` and `dump-prompt --wire` prints it to stdout, so the +/// same bytes are reviewable either way. +pub fn render(dumped: &DumpedPrompt) -> String { + let prompt_bytes = dumped.text.len(); + let tool_bytes = total_tool_bytes(dumped); + let total = prompt_bytes + tool_bytes; + + let mut out = String::with_capacity(total + 4096); + + out.push_str("════════════════════════════════════════════════════════════════════\n"); + out.push_str(" WHAT THE MODEL RECEIVES, BEFORE THE USER SAYS ANYTHING\n"); + out.push_str("════════════════════════════════════════════════════════════════════\n"); + let _ = writeln!(out, " agent {}", dumped.agent_id); + if let Some(toolkit) = &dumped.toolkit { + let _ = writeln!(out, " toolkit {toolkit}"); + } + let _ = writeln!(out, " model {}", dumped.model); + let _ = writeln!(out, " workspace {}", dumped.workspace_dir.display()); + out.push('\n'); + let _ = writeln!( + out, + " system prompt {:>10} B ~{:>7} tok", + thousands(prompt_bytes), + thousands(est_tokens(prompt_bytes)) + ); + let _ = writeln!( + out, + " tool schemas {:>10} B ~{:>7} tok ({} tools, minified as sent)", + thousands(tool_bytes), + thousands(est_tokens(tool_bytes)), + dumped.tool_specs.len() + ); + let _ = writeln!( + out, + " ───────────── {:>10} B ~{:>7} tok charged on EVERY turn", + thousands(total), + thousands(est_tokens(total)) + ); + out.push('\n'); + + out.push_str("────────────────────────────────────────────────────────────────────\n"); + let _ = writeln!( + out, + " 1/2 SYSTEM PROMPT · role: system · {} B · verbatim", + thousands(prompt_bytes) + ); + out.push_str("────────────────────────────────────────────────────────────────────\n\n"); + out.push_str(&dumped.text); + if !dumped.text.ends_with('\n') { + out.push('\n'); + } + + out.push('\n'); + out.push_str("────────────────────────────────────────────────────────────────────\n"); + let _ = writeln!( + out, + " 2/2 TOOL SCHEMAS · {} tools · {} B · one per line, minified", + dumped.tool_specs.len(), + thousands(tool_bytes) + ); + out.push_str("────────────────────────────────────────────────────────────────────\n\n"); + + if dumped.tool_specs.is_empty() { + // Not an omission, and worth saying so: an agent can legitimately + // advertise nothing (`named = []`), and a blank section here would read + // like the dumper failed rather than like the agent is tool-less. + out.push_str("(none — this agent advertises no tools)\n"); + return out; + } + + // Widest-first, so the reader meets the expensive schemas before the cheap + // ones. Registration order carries no information anyone wants here, and + // the point of this file is to name what to cut. + let mut ordered: Vec<(usize, &serde_json::Value)> = dumped + .tool_specs + .iter() + .map(|spec| (tool_schema_bytes(spec), spec)) + .collect(); + ordered.sort_by(|a, b| { + b.0.cmp(&a.0).then_with(|| { + let name = |v: &serde_json::Value| { + v.get("name") + .and_then(|n| n.as_str()) + .unwrap_or_default() + .to_string() + }; + name(a.1).cmp(&name(b.1)) + }) + }); + + for (bytes, spec) in ordered { + let name = spec + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or(""); + let _ = writeln!(out, "# {name} ({} B)", thousands(bytes)); + let _ = writeln!( + out, + "{}", + serde_json::to_string(spec).unwrap_or_else(|_| "{}".to_string()) + ); + out.push('\n'); + } + + out +} + +#[cfg(test)] +#[path = "wire_tests.rs"] +mod tests; diff --git a/src/openhuman/agent/debug/wire_tests.rs b/src/openhuman/agent/debug/wire_tests.rs new file mode 100644 index 0000000000..3bd7e1264a --- /dev/null +++ b/src/openhuman/agent/debug/wire_tests.rs @@ -0,0 +1,132 @@ +//! Tests for the wire dump. +//! +//! The load-bearing one is `schemas_are_minified_not_pretty_printed`: the whole +//! reason this renderer exists is that the pre-existing `.tools.json` sidecar +//! was pretty-printed, which overstates every schema by roughly a third. + +use super::*; +use std::path::PathBuf; + +fn dump(text: &str, specs: Vec) -> DumpedPrompt { + DumpedPrompt { + agent_id: "test_agent".to_string(), + toolkit: None, + mode: "session", + model: "test-model".to_string(), + workspace_dir: PathBuf::from("/tmp/ws"), + text: text.to_string(), + tool_names: specs + .iter() + .filter_map(|s| s.get("name").and_then(|n| n.as_str()).map(str::to_string)) + .collect(), + skill_tool_count: 0, + tool_specs: specs, + } +} + +fn spec(name: &str, description: &str) -> serde_json::Value { + serde_json::json!({ + "name": name, + "description": description, + "parameters": { "type": "object", "properties": { "q": { "type": "string" } } } + }) +} + +#[test] +fn the_prompt_is_reproduced_verbatim() { + // Byte-for-byte: the point of the artefact is that it can be diffed and + // counted against the real thing. Any reformatting here would make the + // numbers in the header describe a different document to the one below it. + let text = "# Agent\n\nLine one.\n\n## Section\n\tindented\n"; + let rendered = render(&dump(text, vec![])); + assert!( + rendered.contains(text), + "the exact prompt bytes must appear in the output" + ); +} + +#[test] +fn schemas_are_minified_not_pretty_printed() { + // The reason this renderer exists. `serde_json::to_vec_pretty` — what the + // `.tools.json` sidecar uses — pads every schema with indentation the model + // never receives, so a reader auditing that file is reading an inflated + // number for every single tool. + let s = spec("search", "Find things."); + let rendered = render(&dump("prompt", vec![s.clone()])); + + let minified = serde_json::to_string(&s).unwrap(); + let pretty = serde_json::to_string_pretty(&s).unwrap(); + assert!(rendered.contains(&minified), "must carry the minified form"); + assert!( + !rendered.contains(&pretty), + "must not carry the pretty-printed form" + ); + assert!( + pretty.len() > minified.len(), + "the two forms must actually differ, or this test proves nothing" + ); +} + +#[test] +fn the_header_totals_the_two_halves() { + let s = spec("search", "Find things."); + let d = dump("abcdefghij", vec![s.clone()]); + let expected = 10 + serde_json::to_string(&s).unwrap().len(); + let rendered = render(&d); + + assert_eq!( + total_tool_bytes(&d), + serde_json::to_string(&s).unwrap().len() + ); + assert!( + rendered.contains(&thousands(expected)), + "header must report prompt + tools as one figure ({expected})" + ); +} + +#[test] +fn tools_are_ordered_widest_first() { + // The file's job is to name what to cut, so the expensive schema is the one + // the reader should meet first. + let small = spec("s", "x"); + let large = spec("l", &"y".repeat(400)); + let rendered = render(&dump("p", vec![small, large])); + let pos_large = rendered.find("# l (").expect("large tool listed"); + let pos_small = rendered.find("# s (").expect("small tool listed"); + assert!(pos_large < pos_small, "widest schema must come first"); +} + +#[test] +fn a_tool_less_agent_says_so_rather_than_rendering_an_empty_section() { + // `named = []` is a real declaration two shipped agents make. A blank + // section would read as a broken dumper rather than a tool-less agent. + let rendered = render(&dump("prompt", vec![])); + assert!(rendered.contains("advertises no tools")); + assert!(rendered.contains("0 tools")); +} + +#[test] +fn every_tool_appears_with_its_own_byte_count() { + let specs = vec![spec("alpha", "a"), spec("beta", "b"), spec("gamma", "c")]; + let d = dump("p", specs.clone()); + let rendered = render(&d); + for s in &specs { + let name = s["name"].as_str().unwrap(); + assert!(rendered.contains(&format!("# {name} (")), "missing {name}"); + } + // And the per-tool figures must sum to the header's total, or the file + // would be internally inconsistent — the failure mode that makes a budget + // report untrustworthy. + let summed: usize = specs.iter().map(tool_schema_bytes).sum(); + assert_eq!(summed, total_tool_bytes(&d)); +} + +#[test] +fn thousands_groups_digits_without_mangling_short_numbers() { + assert_eq!(thousands(0), "0"); + assert_eq!(thousands(7), "7"); + assert_eq!(thousands(999), "999"); + assert_eq!(thousands(1_000), "1,000"); + assert_eq!(thousands(29_821), "29,821"); + assert_eq!(thousands(1_073_644), "1,073,644"); +} diff --git a/src/openhuman/agent/dispatcher.rs b/src/openhuman/agent/dispatcher.rs index 7824e50f38..eae44b71df 100644 --- a/src/openhuman/agent/dispatcher.rs +++ b/src/openhuman/agent/dispatcher.rs @@ -163,6 +163,7 @@ fn to_outcomes(results: &[ToolExecutionResult]) -> Vec { output: result.output.clone(), success: result.success, tool_call_id: result.tool_call_id.clone(), + trusted_verbatim: false, }) .collect() } @@ -188,6 +189,7 @@ fn to_transcript_entry(message: &ConversationMessage) -> TranscriptEntry { .map(|result| ToolResultEntry { tool_call_id: result.tool_call_id.clone(), content: result.content.clone(), + trusted_verbatim: false, }) .collect(), ), @@ -247,6 +249,7 @@ fn from_dialect_message(message: DialectMessage) -> ChatMessage { role: message.role.as_str().to_string(), content: message.content, extra_metadata: message.extra_metadata, + cache_breakpoints: Vec::new(), } } @@ -290,7 +293,12 @@ fn dispatch_format_results( dialect: &dyn ToolDialect, results: &[ToolExecutionResult], ) -> ConversationMessage { - from_transcript_entry(dialect.format_results(&to_outcomes(results))) + dialect + .format_results(&to_outcomes(results)) + .into_iter() + .map(from_transcript_entry) + .next() + .unwrap_or_else(|| ConversationMessage::ToolResults(Vec::new())) } fn dispatch_provider_messages( diff --git a/src/openhuman/agent/harness/definition.rs b/src/openhuman/agent/harness/definition.rs index 4fcfcc4c57..7557b142a7 100644 --- a/src/openhuman/agent/harness/definition.rs +++ b/src/openhuman/agent/harness/definition.rs @@ -26,3 +26,11 @@ mod tests; include!("definition_part_01.rs"); include!("definition_part_02.rs"); + +/// Sentinel used to represent an explicit zero-tool scope. +pub const NO_TOOLS_SENTINEL: &str = "__no_tools__"; + +/// Returns whether a visible-tool set represents an explicit zero-tool scope. +pub fn is_empty_tool_scope(visible: &std::collections::HashSet) -> bool { + visible.is_empty() || (visible.len() == 1 && visible.contains(NO_TOOLS_SENTINEL)) +} diff --git a/src/openhuman/agent/harness/session/builder/builder_tests.rs b/src/openhuman/agent/harness/session/builder/builder_tests.rs index 801ef5aaae..f3fc3cfca2 100644 --- a/src/openhuman/agent/harness/session/builder/builder_tests.rs +++ b/src/openhuman/agent/harness/session/builder/builder_tests.rs @@ -312,3 +312,112 @@ fn use_skill_survives_a_ceiling_that_excludes_it_when_a_pack_is_still_reachable( load.description ); } + +// ───────────────────────────────────────────────────────────────────────────── +// `named = []` means zero tools, not every tool. +// +// The harness's visible-tool set uses empty as its "no filter" sentinel, so an +// agent declaring an empty named scope was handed the entire registry — the +// exact opposite of what it asked for. `summarizer` and `trigger_triage` both +// declare `named = []` in their shipped `agent.toml`, and both were carrying +// 109 tools / 82,986 B of schema apiece: 18% of the fleet's whole fixed prefix, +// on the two agents that had asked for none. `trigger_triage`'s own comment +// says local 1B-class models are unreliable at nested tool calls, "so we keep +// the turn flat" — so this was not only waste, it was actively working against +// the thing the author documented. +// +// `NO_TOOLS_SENTINEL` is how an empty belt survives a set whose empty state is +// already spoken for. +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn an_empty_named_scope_advertises_no_tools_at_all() { + use crate::openhuman::agent::harness::session::types::Agent; + + // `summarizer` is a shipped definition with `named = []`. Using the real + // one rather than a fixture is deliberate: the bug was in how a real + // declaration was read, and a fixture could drift away from it. + // Tolerant of an already-initialised singleton: this binary shares one + // `OnceLock` across every test, so whether we are first is a property of + // test ordering, not of this test. + let _ = + crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::init_global_builtins( + ); + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + let agent = Agent::from_config_for_agent(&config, "summarizer") + .expect("summarizer is a shipped agent definition"); + + let visible = agent.visible_tool_names_for_test(); + let real: Vec<&String> = visible + .iter() + .filter(|n| n.as_str() != crate::openhuman::agent::harness::definition::NO_TOOLS_SENTINEL) + .collect(); + assert!( + real.is_empty(), + "an agent declaring `named = []` must advertise nothing, got: {real:?}" + ); +} + +#[tokio::test] +async fn a_zero_tool_agent_does_not_gain_the_compaction_recovery_tool() { + // `ensure_recovery_tool_visible` joins the recovery tool to any non-empty + // named belt, and the sentinel makes a zero-tool belt non-empty for the + // first time. Without `is_empty_tool_scope` there, "no tools" would have + // quietly become "one tool" — and there is nothing for it to recover, + // because an agent with no tools produces no tool output to truncate. + use crate::openhuman::agent::harness::session::types::Agent; + use crate::openhuman::inference::tokenjuice::RETRIEVE_TOOL_NAME; + + // Tolerant of an already-initialised singleton: this binary shares one + // `OnceLock` across every test, so whether we are first is a property of + // test ordering, not of this test. + let _ = + crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::init_global_builtins( + ); + + let tmp = tempfile::TempDir::new().unwrap(); + let config = test_config(&tmp); + let agent = Agent::from_config_for_agent(&config, "trigger_triage") + .expect("trigger_triage is a shipped agent definition"); + + assert!( + !agent + .visible_tool_names_for_test() + .contains(RETRIEVE_TOOL_NAME), + "a deliberately tool-less agent must not be handed the recovery tool" + ); +} + +#[test] +fn the_no_tools_sentinel_can_never_name_a_real_tool() { + // The value is load-bearing: it works only because no registry can contain + // it. Leading underscores are not a legal tool name for any provider's + // function-calling schema, which is why this shape was chosen. + use crate::openhuman::agent::harness::definition::NO_TOOLS_SENTINEL; + assert!(NO_TOOLS_SENTINEL.starts_with("__")); + assert!(!NO_TOOLS_SENTINEL.chars().next().unwrap().is_alphanumeric()); +} + +#[test] +fn is_empty_tool_scope_distinguishes_the_three_states() { + use crate::openhuman::agent::harness::definition::{is_empty_tool_scope, NO_TOOLS_SENTINEL}; + use std::collections::HashSet; + + // Unset — the historical "everything" sentinel. + assert!(is_empty_tool_scope(&HashSet::new())); + // Deliberately empty. + let sentinel: HashSet = [NO_TOOLS_SENTINEL.to_string()].into_iter().collect(); + assert!(is_empty_tool_scope(&sentinel)); + // A real belt is neither. + let real: HashSet = ["shell".to_string()].into_iter().collect(); + assert!(!is_empty_tool_scope(&real)); + // The sentinel alongside a real tool is not an empty scope — that + // combination should never be built, but reading it as "empty" would hide + // a real tool from the belt rather than surface the mistake. + let mixed: HashSet = [NO_TOOLS_SENTINEL.to_string(), "shell".to_string()] + .into_iter() + .collect(); + assert!(!is_empty_tool_scope(&mixed)); +} diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 6c12f8cae7..6b3666fa3d 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -7,6 +7,7 @@ use crate::openhuman::agent::context::prompt::SystemPromptBuilder; use crate::openhuman::agent::dispatcher::{ NativeToolDispatcher, PFormatToolDispatcher, XmlToolDispatcher, }; +use crate::openhuman::agent::harness::definition::NO_TOOLS_SENTINEL; use crate::openhuman::agent::harness::definition::{ AgentDefinitionRegistry, PromptSource, ToolScope, }; @@ -832,9 +833,40 @@ impl Agent { ToolScope::Named(names) => { let mut set: std::collections::HashSet = names.iter().cloned().collect(); + // Only the *advertised* ones. A synthesised tool that + // reports `ToolExposure::Hidden` is a member of a + // collapsed tool — today every `ArchetypeDelegationTool`, + // whose family the single `delegate_to` tool now stands + // for. Inserting it here would put it back on the wire + // beside the tool that replaced it, shipping both + // surfaces and saving nothing. + // + // This is not the same judgement as + // `strip_deferred_from_visible`, which deliberately + // leaves a hand-written `[tools] named` belt alone. That + // restraint is about not second-guessing a human's + // choice; these names were never chosen by a human, they + // are inserted right here. Hiding one removes nothing an + // author asked for. + // + // The tool stays in `synthed`, so it stays registered + // and dispatchable for a replayed transcript or a saved + // skill that names it — exactly like a packed tool. for t in &synthed { + if t.exposure() == crate::openhuman::tools::traits::ToolExposure::Hidden + { + continue; + } set.insert(t.name().to_string()); } + // `named = []` means zero tools. An empty set here is + // the harness's "no filter" sentinel and would advertise + // the whole registry instead — the exact inversion that + // handed `summarizer` and `trigger_triage` 109 tools + // each. Spell the empty belt so it survives. + if set.is_empty() { + set.insert(NO_TOOLS_SENTINEL.to_string()); + } Some(set) } ToolScope::Wildcard => None, @@ -885,7 +917,17 @@ impl Agent { tool scope" ); let filter: Option> = match &def.tools { - ToolScope::Named(names) => Some(names.iter().cloned().collect()), + ToolScope::Named(names) => { + let mut set: std::collections::HashSet = + names.iter().cloned().collect(); + // Same rule as the branch above: an empty named scope + // is zero tools, and an empty set would mean the + // opposite. + if set.is_empty() { + set.insert(NO_TOOLS_SENTINEL.to_string()); + } + Some(set) + } ToolScope::Wildcard => None, }; (Vec::new(), filter) @@ -912,6 +954,7 @@ impl Agent { Some(set) => set, None => delegation_tools .iter() + .filter(|t| t.exposure() != crate::openhuman::tools::traits::ToolExposure::Hidden) .map(|t| t.name().to_string()) .collect(), }; @@ -932,7 +975,15 @@ impl Agent { visible = tools .iter() .map(|t| t.name().to_string()) - .chain(delegation_tools.iter().map(|t| t.name().to_string())) + .chain( + delegation_tools + .iter() + .filter(|t| { + t.exposure() + != crate::openhuman::tools::traits::ToolExposure::Hidden + }) + .map(|t| t.name().to_string()), + ) .filter(|name| !definition_disallows_tool(&def.disallowed_tools, name)) .collect(); } @@ -941,6 +992,11 @@ impl Agent { .retain(|name| !definition_disallows_tool(&def.disallowed_tools, name)); } } + // Disallowing every tool must remain a zero-tool scope. An + // empty visible set means "no filter" to the harness. + if visible.is_empty() { + visible.insert(NO_TOOLS_SENTINEL.to_string()); + } } } @@ -968,7 +1024,7 @@ impl Agent { // non-empty with an unregistered name so it advertises and // permits zero tools rather than accidentally broadening. if visible.is_empty() { - visible.insert("__profile_no_tools__".to_string()); + visible.insert(NO_TOOLS_SENTINEL.to_string()); } } } @@ -1219,12 +1275,15 @@ impl Agent { ) })) .profile_memory_storage(memory_subdir, session_raw_subdir) - .workflows( - crate::openhuman::skills::load_workflow_metadata_for_profile( + .workflows({ + let mut catalogue = crate::openhuman::skills::load_workflow_metadata_for_profile( &config.workspace_dir, profile_skills_root.as_deref(), - ), - ) + ); + #[cfg(feature = "flows")] + catalogue.extend(crate::openhuman::flows::catalogue::flow_entries(config)); + catalogue + }) .auto_save(config.memory.auto_save) .post_turn_hooks(post_turn_hooks) .learning_enabled(config.learning.enabled) @@ -1420,19 +1479,6 @@ pub(crate) fn provider_role_for(agent_id: &str, default_model: Option<&str>) -> #[path = "factory_provider_role_tests_tests.rs"] mod provider_role_tests; -/// Section D — derive the top-level chat turn's per-profile workspace -/// descriptor. Shared by [`Agent::build_session_agent_inner`] and its unit tests -/// so the two can never drift. -/// -/// Returns a [`WorkspaceDescriptor`](tinyagents_harness::workspace::WorkspaceDescriptor) -/// rooted at `/profiles/` when `profile` opts into -/// `dedicated_workspace` and its id passes validation (via -/// [`dedicated_workspace_dir`](crate::openhuman::agent::profiles::dedicated_workspace_dir)), -/// creating the dir as a side effect; `None` for the shared-workspace common case, -/// for legacy ids that fail validation, and when the directory can't be created -/// (all three fall back to the shared `action_dir` cwd rather than binding tools -/// to a nonexistent dir). The returned descriptor propagates to subagents — see -/// the deliberate-isolation note at the call site. pub(crate) fn derive_profile_workspace_descriptor( action_dir: &std::path::Path, profile: Option<&crate::openhuman::agent::profiles::AgentProfile>, @@ -1465,24 +1511,6 @@ pub(crate) fn derive_profile_workspace_descriptor( ) } -/// Section D, embedder variant — the turn's workspace descriptor from the -/// per-turn root an embedder scoped via -/// [`turn_workspace::with_workspace`](crate::openhuman::agent::turn_workspace::with_workspace). -/// -/// Returns a [`WorkspaceDescriptor`](tinyagents_harness::workspace::WorkspaceDescriptor) -/// rooted at the scoped directory so this turn's acting tools (shell, file, -/// git) resolve their default cwd there instead of the shared `action_dir`. -/// `None` — every caller that scoped nothing — leaves the shared-`action_dir` -/// behaviour byte-identical. -/// -/// The root is only honoured when it is an existing directory: binding every -/// acting tool to a cwd that does not exist would turn a host's stale path into -/// an unexplained failure in each individual tool, and the shared `action_dir` -/// is the better fallback (same reasoning as the profile variant's -/// create-failure path). -/// -/// The policy id is a fixed label rather than the path: it is surfaced in tool -/// logs, and a host's checkout path is not something to spread through them. fn derive_turn_workspace_descriptor() -> Option { let root = crate::openhuman::agent::turn_workspace::current()?; @@ -1516,13 +1544,6 @@ fn build_profile_security( } } -/// Section D — per-profile dedicated-workspace descriptor seam. -/// -/// These tests exercise the **production** [`derive_profile_workspace_descriptor`] -/// directly (the same function the session builder calls), so they cannot drift -/// from the real seam. They pin that the descriptor root points at -/// `/profiles/` for an opted-in profile, and that shared/legacy -/// profiles produce no descriptor (so the shared `action_dir` cwd is preserved). #[cfg(test)] #[path = "factory_profile_workspace_descriptor_tests_tests.rs"] mod profile_workspace_descriptor_tests; diff --git a/src/openhuman/agent/harness/session/builder/mod.rs b/src/openhuman/agent/harness/session/builder/mod.rs index bb7faf945d..e38038b441 100644 --- a/src/openhuman/agent/harness/session/builder/mod.rs +++ b/src/openhuman/agent/harness/session/builder/mod.rs @@ -165,7 +165,12 @@ pub(super) fn visible_tool_specs_for_policy( /// means "no filter" (all tools visible), so it is left untouched — including /// the deliberately tool-less `Named([])` case, which must stay tool-less. pub(super) fn ensure_recovery_tool_visible(visible: &mut std::collections::HashSet) { - if !visible.is_empty() { + // `is_empty_tool_scope`, not `is_empty`: a belt holding only + // `NO_TOOLS_SENTINEL` is a deliberate zero-tool agent, and the compaction + // recovery tool has nothing to recover for one — there are no tool outputs + // to truncate. Adding it would turn "no tools" into "one tool" and put a + // schema back on a turn whose whole point is that it stays flat. + if !crate::openhuman::agent::harness::definition::is_empty_tool_scope(visible) { for name in crate::openhuman::inference::tokenjuice::RECOVERY_TOOL_NAMES { visible.insert((*name).to_string()); } diff --git a/src/openhuman/agent/harness/session/transcript_history_tests.rs b/src/openhuman/agent/harness/session/transcript_history_tests.rs index 47e52a51fc..1d87fbe9c2 100644 --- a/src/openhuman/agent/harness/session/transcript_history_tests.rs +++ b/src/openhuman/agent/harness/session/transcript_history_tests.rs @@ -221,6 +221,7 @@ fn chat(role: &str, content: &str) -> ChatMessage { role: role.into(), content: content.into(), extra_metadata: None, + cache_breakpoints: Vec::new(), } } diff --git a/src/openhuman/agent/harness/session/transcript_part_02.rs b/src/openhuman/agent/harness/session/transcript_part_02.rs index 8737aab9b2..80ec500edb 100644 --- a/src/openhuman/agent/harness/session/transcript_part_02.rs +++ b/src/openhuman/agent/harness/session/transcript_part_02.rs @@ -214,6 +214,7 @@ fn message_from_line(ml: MessageLine) -> ChatMessage { role: ml.role, content: ml.content, extra_metadata: ml.extra_metadata, + cache_breakpoints: Vec::new(), }; if let Some(turn_usage) = turn_usage.as_ref() { attach_turn_usage_metadata(&mut message, turn_usage); @@ -365,6 +366,7 @@ fn display_message_from_line(ml: MessageLine) -> DisplayMessage { role: ml.role, content: ml.content, extra_metadata: ml.extra_metadata, + cache_breakpoints: Vec::new(), }, } } diff --git a/src/openhuman/agent/harness/session/transcript_part_03.rs b/src/openhuman/agent/harness/session/transcript_part_03.rs index 215c7ee114..5077319662 100644 --- a/src/openhuman/agent/harness/session/transcript_part_03.rs +++ b/src/openhuman/agent/harness/session/transcript_part_03.rs @@ -430,6 +430,7 @@ fn parse_legacy_messages(raw: &str) -> Result> { role, content: content.replace(LEGACY_MSG_CLOSE_ESCAPED, LEGACY_MSG_CLOSE), extra_metadata: None, + cache_breakpoints: Vec::new(), }); search_from = content_start + content_end_rel + LEGACY_MSG_CLOSE.len(); continue; @@ -441,6 +442,7 @@ fn parse_legacy_messages(raw: &str) -> Result> { role, content: content.replace(LEGACY_MSG_CLOSE_ESCAPED, LEGACY_MSG_CLOSE), extra_metadata: None, + cache_breakpoints: Vec::new(), }); search_from = content_start + content_end_rel + close_tag.len(); diff --git a/src/openhuman/agent/harness/session/turn/tools.rs b/src/openhuman/agent/harness/session/turn/tools.rs index 53a07fc7b1..017bbdf59b 100644 --- a/src/openhuman/agent/harness/session/turn/tools.rs +++ b/src/openhuman/agent/harness/session/turn/tools.rs @@ -382,10 +382,14 @@ impl Agent { log::debug!( "[agent_loop] refreshing installed-skills metadata (trigger={trigger}, profile_local_skills_active={profile_local_skills_active})" ); - let latest = crate::openhuman::skills::load_workflow_metadata_for_profile( + let mut latest = crate::openhuman::skills::load_workflow_metadata_for_profile( &self.workspace_dir, profile_skills_root.as_deref(), ); + #[cfg(feature = "flows")] + if let Some(config) = self.runtime_config.as_deref() { + latest.extend(crate::openhuman::flows::catalogue::flow_entries(config)); + } log::debug!( "[agent_loop] refreshed installed-skills metadata (trigger={trigger}, profile_local_skills_active={profile_local_skills_active}, workflow_count={})", latest.len() diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index 03fab9f629..94c4af08d4 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -91,6 +91,7 @@ impl ChatModel<()> for SequenceProvider { _ => message.text(), }, extra_metadata: None, + cache_breakpoints: Vec::new(), }) .collect(), ); diff --git a/src/openhuman/agent/harness/subagent_runner/extract_tool.rs b/src/openhuman/agent/harness/subagent_runner/extract_tool.rs index 3cf5ca0f28..2113db7b49 100644 --- a/src/openhuman/agent/harness/subagent_runner/extract_tool.rs +++ b/src/openhuman/agent/harness/subagent_runner/extract_tool.rs @@ -534,18 +534,21 @@ fn write_extract_transcript( role: "system".into(), content: system_prompt.to_string(), extra_metadata: None, + cache_breakpoints: Vec::new(), }, ChatMessage { id: None, role: "user".into(), content: user_prompt.to_string(), extra_metadata: None, + cache_breakpoints: Vec::new(), }, ChatMessage { id: None, role: "assistant".into(), content: assistant_text, extra_metadata: None, + cache_breakpoints: Vec::new(), }, ]; diff --git a/src/openhuman/agent/messages.rs b/src/openhuman/agent/messages.rs index 7ba396d8ea..70c77f58f5 100644 --- a/src/openhuman/agent/messages.rs +++ b/src/openhuman/agent/messages.rs @@ -16,6 +16,16 @@ pub struct ChatMessage { pub content: String, #[serde(default, skip_serializing)] pub extra_metadata: Option, + /// Ascending byte offsets into [`Self::content`] at which the provider may + /// place a prompt-cache breakpoint. Only meaningful on the system message. + /// + /// `skip_serializing` like `id` and `extra_metadata` above: these are a + /// property of *this call*, derived from the freshly assembled prompt, and + /// writing them into the JSONL transcript would persist offsets that stop + /// matching the moment the prompt is rebuilt. `serde(default)` keeps every + /// record already on disk loadable. + #[serde(default, skip_serializing)] + pub cache_breakpoints: Vec, } impl ChatMessage { @@ -25,6 +35,43 @@ impl ChatMessage { role: "system".into(), content: content.into(), extra_metadata: None, + cache_breakpoints: Vec::new(), + } + } + + /// A system message carrying prompt-cache breakpoints. + /// + /// `breakpoints` are ends-of-tier from + /// [`crate::openhuman::agent::prompts::SystemPromptBuilder::build_tiered`]. + /// Out-of-range or non-ascending offsets are dropped rather than trusted: + /// a bad offset would split the prompt mid-sentence and the model would + /// read the damage, whereas a dropped one costs only a cache miss. + pub fn system_tiered(content: impl Into, breakpoints: Vec) -> Self { + let content = content.into(); + let mut previous = 0usize; + let breakpoints: Vec = breakpoints + .into_iter() + .filter(|&offset| { + let ok = + offset > previous && offset < content.len() && content.is_char_boundary(offset); + if ok { + previous = offset; + } else { + tracing::warn!( + offset, + len = content.len(), + "[prompts] dropping an invalid cache breakpoint" + ); + } + ok + }) + .collect(); + Self { + id: None, + role: "system".into(), + content, + extra_metadata: None, + cache_breakpoints: breakpoints, } } @@ -34,6 +81,7 @@ impl ChatMessage { role: "user".into(), content: content.into(), extra_metadata: None, + cache_breakpoints: Vec::new(), } } @@ -43,6 +91,7 @@ impl ChatMessage { role: "assistant".into(), content: content.into(), extra_metadata: None, + cache_breakpoints: Vec::new(), } } @@ -52,6 +101,7 @@ impl ChatMessage { role: "tool".into(), content: content.into(), extra_metadata: None, + cache_breakpoints: Vec::new(), } } } diff --git a/src/openhuman/agent/multimodal.rs b/src/openhuman/agent/multimodal.rs index 2f7b023735..209ca4a962 100644 --- a/src/openhuman/agent/multimodal.rs +++ b/src/openhuman/agent/multimodal.rs @@ -73,7 +73,6 @@ pub struct PreparedMessages { } // ── Config mapping ─────────────────────────────────────────────────────── -// // Follows the `session_config_from` precedent in `openhuman::tinyagents::config`: // the crate owns the struct, the host maps its schema into it. The clamping // stays crate-side, so these are plain field copies — if they ever grow a rule, @@ -113,7 +112,6 @@ fn remote_client() -> Client { } // ── Text extraction ────────────────────────────────────────────────────── - /// The host's [`TextExtractor`]: PDF text through the `tinydocs` module, #[cfg_attr(feature = "documents", doc = "bounded by [`PDF_EXTRACTION_TIMEOUT`].")] #[cfg_attr( @@ -347,6 +345,7 @@ pub async fn prepare_messages_for_provider( role: message.role.clone(), content, extra_metadata: message.extra_metadata.clone(), + cache_breakpoints: Vec::new(), }); } @@ -543,6 +542,7 @@ pub fn rehydrate_image_placeholders(messages: &[ChatMessage]) -> Vec String { +pub(super) fn render_structured_handoff(prompt: &str, args: &Value) -> String { let mut out = String::new(); out.push_str("Task:\n"); out.push_str(prompt.trim()); @@ -219,6 +219,23 @@ fn render_structured_handoff(prompt: &str, args: &Value) -> String { out } +pub(super) fn delegation_envelope_properties() -> Value { + serde_json::json!({ + "prompt": {"type": "string"}, + "objective": {"type": "string"}, + "evidence": {"type": "array", "items": {"type": "string"}}, + "constraints": {"type": "array", "items": {"type": "string"}}, + "must_not_assume": {"type": "array", "items": {"type": "string"}}, + "expected_output": {"type": "string"}, + "citation_requirement": { + "type": "string", + "enum": ["none", "file_paths", "urls", "retrieval_hits", "tool_outputs"] + }, + "model": {"type": "string"}, + "blocking": {"type": "boolean"} + }) +} + fn push_optional_string(out: &mut String, label: &str, value: Option<&Value>) { let Some(text) = value.and_then(Value::as_str).map(str::trim) else { return; diff --git a/src/openhuman/agent/orchestration/tools/collapsed_delegation.rs b/src/openhuman/agent/orchestration/tools/collapsed_delegation.rs new file mode 100644 index 0000000000..5c71eb75c1 --- /dev/null +++ b/src/openhuman/agent/orchestration/tools/collapsed_delegation.rs @@ -0,0 +1,299 @@ +//! `delegate_to` — every archetype hand-off as one action-dispatched tool. +//! +//! Replaces the per-sub-agent fan-out where `collect_orchestrator_tools` +//! synthesised one [`ArchetypeDelegationTool`] per named sub-agent. On the +//! Master Agent that was **16 tools worth 17,746 bytes, 41% of its whole +//! tool-schema budget** — and the schemas were not 16 different things. Every +//! one of them carried a byte-identical copy of the delegation envelope +//! (`prompt` / `objective` / `evidence` / `constraints` / `must_not_assume` / +//! `expected_output` / `citation_requirement` / `model` / `blocking`), because +//! `ArchetypeDelegationTool::parameters_schema` is one `json!` literal that +//! does not read `self`. The only thing that differed between the 16 was the +//! name and the target's `when_to_use` line. +//! +//! So the envelope is emitted once here and the 16 names become an `agent` +//! enum, with each target's `when_to_use` kept verbatim in the description — +//! the routing information survives in full, the repetition does not. +//! +//! This is the same collapse [`SkillDelegationTool`] already applied to the +//! *other* delegation axis (#1335): one `delegate_to_integrations_agent` with +//! a `toolkit` argument, instead of one `delegate_` per connected +//! Composio integration. That change made the schema constant in the +//! integration dimension; this one makes it constant in the sub-agent +//! dimension. The two are now consistent. +//! +//! # Why collapse rather than pack +//! +//! The toolpack mechanism (`load_skill` / `use_skill`) exists and would also +//! remove these bytes, but it is the wrong tool for this family. A pack costs +//! a round trip on first use, which is the right trade for a capability most +//! turns never touch — crypto, MCP setup, the `.pptx` writer. Delegation is +//! the orchestrator's *job*; putting a round trip in front of it would tax the +//! single most common thing it does, on almost every turn. +//! +//! Collapsing has the opposite cost profile: one extra enum field on a call +//! the model was making anyway, and no round trip at all. Frequency of use is +//! what separates the two mechanisms — see `toolpacks::registry`, whose +//! `DELIBERATELY_UNPACKED_FLEET_TOOLS` note draws the same line for the same +//! reason. +//! +//! # The members stay registered +//! +//! Each `delegate_*` / `research` / `plan` / … tool remains in the registry as +//! [`ToolExposure::Hidden`], exactly like the members of the collapsed `cron` +//! and `memory` tools. They are off the wire, not gone: a replayed transcript, +//! a saved skill, or a flow node that names `research` still resolves. Only +//! the advertised surface shrinks. +//! +//! # The name +//! +//! `delegate_to`, not `delegate`: a config-driven [`DelegateTool`] already +//! claims `delegate` whenever a user hand-writes an `[agents]` block, and the +//! builder's collision guard resolves a clash by dropping the *synthesised* +//! tool. Naming this one `delegate` would therefore have removed the +//! orchestrator's entire delegation surface for exactly those users, silently. +//! It also puts this tool in the same family as its sibling +//! `delegate_to_integrations_agent`. +//! +//! [`DelegateTool`]: crate::openhuman::agent::tools::DelegateTool +//! [`ArchetypeDelegationTool`]: super::ArchetypeDelegationTool +//! [`SkillDelegationTool`]: super::SkillDelegationTool +//! [`ToolExposure::Hidden`]: crate::openhuman::tools::traits::ToolExposure::Hidden + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use super::archetype_delegation::{delegation_envelope_properties, render_structured_handoff}; +use crate::openhuman::tools::traits::{ + PermissionLevel, Tool, ToolCallOptions, ToolCategory, ToolResult, ToolTimeout, +}; +use tinytools::ToolRunContext; + +/// The advertised name. A constant so the synthesis site, the prompt's +/// delegation section and the tests cannot disagree about it. +pub const DELEGATE_TO_TOOL_NAME: &str = "delegate_to"; + +/// One routable sub-agent. +pub struct DelegateTarget { + /// The name this target had when it was its own tool, and the value the + /// `agent` enum takes. Keeping the old name as the enum value is what lets + /// the orchestrator prompt go on naming `research` and `schedule_task` + /// without a rewrite, and keeps dispatch events reading as they did. + pub tool_name: String, + /// The registry id the work is actually handed to. + pub agent_id: String, + /// The target's `when_to_use`, verbatim. + pub description: String, +} + +/// Every archetype hand-off as one tool. +pub struct CollapsedDelegationTool { + targets: Vec, + description: String, +} + +impl CollapsedDelegationTool { + /// Build the collapsed tool, or `None` when there is nothing to route to. + /// + /// `None` rather than an empty enum: a `delegate` tool whose `agent` has no + /// valid value is a schema the model can only call wrongly, and the + /// sibling [`SkillDelegationTool::for_connected`] already returns `None` on + /// an empty toolkit list for the same reason. + /// + /// [`SkillDelegationTool::for_connected`]: super::SkillDelegationTool::for_connected + pub fn for_targets(targets: Vec) -> Option { + if targets.is_empty() { + return None; + } + let description = build_description(&targets); + Some(Self { + targets, + description, + }) + } + + fn resolve(&self, agent: &str) -> Option<&DelegateTarget> { + self.targets.iter().find(|t| t.tool_name == agent) + } + + fn agent_enum(&self) -> Vec<&str> { + self.targets.iter().map(|t| t.tool_name.as_str()).collect() + } + + /// The routable names, for the prompt renderer and the tests. + pub fn target_names(&self) -> Vec<&str> { + self.agent_enum() + } +} + +fn build_description(targets: &[DelegateTarget]) -> String { + let mut buf = String::from( + "Hand a task to a specialist sub-agent. Set `agent` to one of the values below and pass \ + the task as `prompt`. Choose by what the task needs:", + ); + for target in targets { + buf.push_str("\n- `"); + buf.push_str(&prompt_safe(&target.tool_name)); + buf.push('`'); + let trimmed = target.description.trim(); + if !trimmed.is_empty() { + buf.push_str(": "); + buf.push_str(&prompt_safe(trimmed)); + } + } + buf +} + +fn prompt_safe(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '`' => out.push('\''), + ch if ch.is_control() => out.push_str(&format!("\\u{{{:x}}}", ch as u32)), + ch => out.push(ch), + } + } + out +} + +#[async_trait] +impl Tool for CollapsedDelegationTool { + fn name(&self) -> &str { + DELEGATE_TO_TOOL_NAME + } + + fn description(&self) -> &str { + &self.description + } + + /// The envelope, emitted **once**, plus the `agent` selector. + /// + /// The properties come from `delegation_envelope_properties` rather than a + /// second literal: two copies of this object would be two places for the + /// collapsed tool and its hidden members to disagree about what a hand-off + /// carries, and `render_structured_handoff` reads the property names + /// directly. One definition, both callers. + fn parameters_schema(&self) -> Value { + let mut schema = json!({ + "type": "object", + "required": ["agent", "prompt"], + "properties": { + "agent": { + "type": "string", + "enum": self.agent_enum(), + "description": "Which specialist to hand this to." + } + } + }); + let properties = schema["properties"] + .as_object_mut() + .expect("properties is an object literal above"); + if let Value::Object(envelope) = delegation_envelope_properties() { + for (key, value) in envelope { + properties.insert(key, value); + } + } + schema + } + + fn permission_level(&self) -> PermissionLevel { + // Every member declares `Execute`, so there is no per-action variation + // to resolve here. If a target ever needs more, this must become an + // args-aware lookup like `cron`'s — a single level would then be + // laundering one target's risk down to another's. + PermissionLevel::Execute + } + + fn category(&self) -> ToolCategory { + ToolCategory::System + } + + /// Unbounded, matching the member tools this replaces. + /// + /// Under the default `Inherit` policy the whole delegation is hard-killed + /// at the single-tool timeout (120s), truncating any sub-agent run that + /// legitimately takes longer — the Sentry regression (TAURI-RUST-K29, + /// TAURI-RUST-8HB) that put `Unbounded` on `ArchetypeDelegationTool` in the + /// first place. The child bounds its own lifetime through `max_iterations`, + /// the run cancellation token and each inner tool's own timeout. + fn timeout_policy(&self, _args: &Value) -> ToolTimeout { + ToolTimeout::Unbounded + } + + async fn execute(&self, args: Value) -> anyhow::Result { + self.execute_with_context(args, ToolCallOptions::default(), None) + .await + } + + async fn execute_with_context( + &self, + args: Value, + _options: ToolCallOptions, + tool_context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + let requested = args.get("agent").and_then(Value::as_str).map(str::trim); + let Some(target) = requested.and_then(|agent| self.resolve(agent)) else { + return Ok(ToolResult::error(format!( + "`agent` must be one of: {}. Got: {}", + self.agent_enum().join(", "), + requested.filter(|s| !s.is_empty()).unwrap_or("(missing)") + ))); + }; + + let raw_prompt = args + .get("prompt") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + if raw_prompt.is_empty() { + return Ok(ToolResult::error(format!( + "{DELEGATE_TO_TOOL_NAME}: `prompt` is required" + ))); + } + let prompt = render_structured_handoff(&raw_prompt, &args); + + let model_override = args + .get("model") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()); + + // Async by default, exactly as the member tools were: the specialist + // runs as a durable, resumable worker and its result arrives as a new + // chat turn. `blocking: true` is the opt-in for a result that must gate + // this reply. + let blocking = args + .get("blocking") + .and_then(Value::as_bool) + .unwrap_or(false); + let mode = if blocking { + super::dispatch::DispatchMode::Blocking + } else { + super::dispatch::DispatchMode::PreferAsync + }; + + tracing::debug!( + agent = %target.agent_id, + via = %target.tool_name, + "[delegate] dispatch" + ); + // `target.tool_name`, not `DELEGATE_TO_TOOL_NAME`: the dispatch name rides + // into run records and the UI, and reporting every hand-off as + // `delegate` would erase which specialist was chosen from every trace. + super::dispatch_subagent( + &target.agent_id, + &target.tool_name, + &prompt, + None, + model_override, + tool_context, + mode, + ) + .await + } +} + +#[cfg(test)] +#[path = "collapsed_delegation_tests.rs"] +mod tests; diff --git a/src/openhuman/agent/orchestration/tools/collapsed_delegation_tests.rs b/src/openhuman/agent/orchestration/tools/collapsed_delegation_tests.rs new file mode 100644 index 0000000000..6e9a06e523 --- /dev/null +++ b/src/openhuman/agent/orchestration/tools/collapsed_delegation_tests.rs @@ -0,0 +1,261 @@ +//! Tests for the collapsed `delegate_to` tool. +//! +//! The load-bearing ones are the two that would let the collapse silently stop +//! paying for itself: `the_envelope_is_emitted_once` (the saving) and +//! `every_member_is_hidden_so_the_collapse_actually_saves_something` (that +//! nothing ships both surfaces). + +use super::*; +use crate::openhuman::agent::orchestration::tools::ArchetypeDelegationTool; +use crate::openhuman::tools::traits::ToolExposure; + +fn targets() -> Vec { + vec![ + DelegateTarget { + tool_name: "research".to_string(), + agent_id: "researcher".to_string(), + description: "Web research and source gathering.".to_string(), + }, + DelegateTarget { + tool_name: "review_code".to_string(), + agent_id: "code_reviewer".to_string(), + description: "Review a diff for correctness.".to_string(), + }, + ] +} + +fn tool() -> CollapsedDelegationTool { + CollapsedDelegationTool::for_targets(targets()).expect("two targets is not empty") +} + +#[test] +fn an_empty_target_list_produces_no_tool() { + // An `agent` enum with no valid value is a schema the model can only call + // wrongly. Mirrors `SkillDelegationTool::for_connected`. + assert!(CollapsedDelegationTool::for_targets(Vec::new()).is_none()); +} + +#[test] +fn the_schema_advertises_every_target() { + let schema = tool().parameters_schema(); + let listed: Vec<&str> = schema["properties"]["agent"]["enum"] + .as_array() + .expect("enum") + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert_eq!(listed, vec!["research", "review_code"]); +} + +#[test] +fn the_schema_carries_the_whole_delegation_envelope() { + // The collapse must not quietly drop a field the members accepted: + // `render_structured_handoff` reads these exact names, so a missing + // property is a hand-off that silently loses its constraints. + let schema = tool().parameters_schema(); + let props = schema["properties"].as_object().expect("properties"); + for field in [ + "prompt", + "objective", + "evidence", + "constraints", + "must_not_assume", + "expected_output", + "citation_requirement", + "model", + "blocking", + ] { + assert!(props.contains_key(field), "envelope lost `{field}`"); + } +} + +#[test] +fn the_envelope_is_emitted_once() { + // The entire point of the collapse, asserted as a number rather than a + // shape: one tool holding N targets must not cost what N tools cost. + // + // Without this, someone re-introducing a per-target schema (say, to give + // each specialist its own `expected_output` description) would reproduce + // the 17,746-byte regression this file exists to remove, and every other + // test here would still pass. + let many: Vec = (0..16) + .map(|i| DelegateTarget { + tool_name: format!("target_{i}"), + agent_id: format!("agent_{i}"), + description: "A specialist.".to_string(), + }) + .collect(); + let collapsed = CollapsedDelegationTool::for_targets(many).expect("non-empty"); + let bytes = serde_json::to_string(&collapsed.parameters_schema()) + .expect("schema serialises") + .len(); + + // One envelope (~900 B) plus 16 short enum values. Sixteen separate + // schemas were ~17,700 B; the ceiling here is deliberately far below that + // and far above the real figure, so it catches a reintroduced fan-out + // without failing on ordinary wording changes. + assert!( + bytes < 2_000, + "16 targets cost {bytes} B of schema — the envelope is being repeated" + ); +} + +#[test] +fn adding_a_target_costs_only_its_name_and_description() { + // The property that makes the schema constant in the sub-agent dimension: + // growth must be linear in the *description*, not in the envelope. + let one = CollapsedDelegationTool::for_targets(vec![DelegateTarget { + tool_name: "research".to_string(), + agent_id: "researcher".to_string(), + description: String::new(), + }]) + .expect("non-empty"); + let two = CollapsedDelegationTool::for_targets(vec![ + DelegateTarget { + tool_name: "research".to_string(), + agent_id: "researcher".to_string(), + description: String::new(), + }, + DelegateTarget { + tool_name: "plan".to_string(), + agent_id: "planner".to_string(), + description: String::new(), + }, + ]) + .expect("non-empty"); + + let cost = |t: &CollapsedDelegationTool| { + serde_json::to_string(&t.parameters_schema()) + .expect("serialises") + .len() + + t.description().len() + }; + // `plan` plus the enum quoting and list punctuation — tens of bytes, not + // the ~1,100 a whole extra tool schema used to cost. + assert!( + cost(&two) - cost(&one) < 60, + "a second target added {} B", + cost(&two) - cost(&one) + ); +} + +#[test] +fn the_description_carries_each_targets_routing_line() { + // The routing information is the one thing the collapse must not lose — + // it is how the model picks a specialist at all. + let tool = tool(); + let description = tool.description(); + assert!(description.contains("`research`")); + assert!(description.contains("Web research and source gathering.")); + assert!(description.contains("`review_code`")); + assert!(description.contains("Review a diff for correctness.")); +} + +#[test] +fn a_target_with_no_when_to_use_still_lists_its_name() { + let tool = CollapsedDelegationTool::for_targets(vec![DelegateTarget { + tool_name: "mystery".to_string(), + agent_id: "mystery_agent".to_string(), + description: " ".to_string(), + }]) + .expect("non-empty"); + assert!(tool.description().contains("`mystery`")); + // No dangling ": " when the description is blank. + assert!(!tool.description().contains("`mystery`: ")); +} + +#[tokio::test] +async fn an_unknown_agent_is_an_error_naming_the_valid_ones() { + let result = tool() + .execute(serde_json::json!({"agent": "researchr", "prompt": "hi"})) + .await + .expect("dispatch does not fail the call"); + assert!(result.is_error); + let text = format!("{result:?}"); + assert!(text.contains("researchr"), "names what was passed: {text}"); + assert!(text.contains("research"), "names the valid ones: {text}"); +} + +#[tokio::test] +async fn a_missing_agent_is_an_error_rather_than_a_default_route() { + // Defaulting to the first target would silently send work to the wrong + // specialist, which is worse than failing. + let result = tool() + .execute(serde_json::json!({"prompt": "hi"})) + .await + .expect("dispatch does not fail the call"); + assert!(result.is_error); + assert!(format!("{result:?}").contains("missing")); +} + +#[tokio::test] +async fn an_empty_prompt_is_rejected_before_dispatch() { + let result = tool() + .execute(serde_json::json!({"agent": "research", "prompt": " "})) + .await + .expect("dispatch does not fail the call"); + assert!(result.is_error); + assert!(format!("{result:?}").contains("prompt")); +} + +#[test] +fn the_timeout_is_unbounded_like_the_members_it_replaces() { + // Inheriting the 120s single-tool deadline would truncate every sub-agent + // run that legitimately takes longer — the Sentry regression that put + // `Unbounded` on `ArchetypeDelegationTool` in the first place. + assert!(matches!( + tool().timeout_policy(&serde_json::json!({})), + crate::openhuman::tools::traits::ToolTimeout::Unbounded + )); +} + +#[test] +fn every_member_is_hidden_so_the_collapse_actually_saves_something() { + // The load-bearing assertion, matching the one on the collapsed `cron` and + // `memory` tools: leaving a member `Direct` would ship both surfaces and + // save nothing, and nothing else in the build would notice. + let member = ArchetypeDelegationTool { + tool_name: "research".to_string(), + agent_id: + crate::openhuman::agent::orchestration::tools::archetype_delegation::DelegationTarget( + "researcher".to_string(), + ), + tool_description: "Web research.".to_string(), + }; + assert_eq!(member.exposure(), ToolExposure::Hidden); + // …while the collapsed tool itself stays on the wire. + assert_eq!(tool().exposure(), ToolExposure::Direct); +} + +#[test] +fn the_member_and_the_collapsed_tool_agree_on_the_envelope() { + // Both call `delegation_envelope_properties`, so this pins that neither + // grew a private copy. A drift here is silent: the collapsed schema would + // advertise a field `render_structured_handoff` never reads. + let member = ArchetypeDelegationTool { + tool_name: "research".to_string(), + agent_id: + crate::openhuman::agent::orchestration::tools::archetype_delegation::DelegationTarget( + "researcher".to_string(), + ), + tool_description: "Web research.".to_string(), + }; + let member_props = member.parameters_schema()["properties"] + .as_object() + .expect("properties") + .keys() + .cloned() + .collect::>(); + + let collapsed = tool().parameters_schema(); + let mut collapsed_props = collapsed["properties"] + .as_object() + .expect("properties") + .keys() + .cloned() + .collect::>(); + // The selector is the collapsed tool's own addition. + assert!(collapsed_props.remove("agent")); + + assert_eq!(member_props, collapsed_props); +} diff --git a/src/openhuman/agent/prompts/builder.rs b/src/openhuman/agent/prompts/builder.rs index 27883448a1..9cdf0e5a43 100644 --- a/src/openhuman/agent/prompts/builder.rs +++ b/src/openhuman/agent/prompts/builder.rs @@ -1,6 +1,21 @@ //! [`SystemPromptBuilder`] — assembles ordered [`PromptSection`]s into a //! final system-prompt string. +/// A rendered system prompt together with the byte offsets at which a provider +/// may place a prompt-cache breakpoint. +/// +/// Offsets are ends-of-tier, in ascending order, and always fall on a UTF-8 +/// character boundary because they are taken at a point where only whole +/// sections have been pushed. At most two are produced today (end of `Stable`, +/// end of `Context`), comfortably inside the four Anthropic accepts. +#[derive(Debug, Clone, Default)] +pub struct TieredPrompt { + /// The assembled prompt — byte-identical to what [`SystemPromptBuilder::build`] returns. + pub text: String, + /// Ascending byte offsets into [`Self::text`]. + pub breakpoints: Vec, +} + use super::render_helpers::sync_workspace_file; use super::sections::*; use super::types::*; @@ -277,14 +292,55 @@ impl SystemPromptBuilder { /// cache-boundary marker to emit because the entire prompt is /// static from the provider's perspective. pub fn build(&self, ctx: &PromptContext<'_>) -> Result { + Ok(self.build_tiered(ctx)?.text) + } + + /// Assemble the prompt **and** report where its cache tiers end. + /// + /// Sections are emitted grouped by [`PromptSection::tier`] — every + /// `Stable` section in declaration order, then every `Context` one, then + /// every `Volatile` one. Within a tier the declaration order is preserved + /// exactly, so this is a stable partition rather than a sort: a section + /// that does not change tier does not change its neighbours. + /// + /// The grouping is the whole point. A prefix is reusable only up to the + /// first byte that differs, so a volatile section emitted early throws away + /// every stable byte behind it. `with_defaults` used to place + /// `UserFilesSection` second and `UserMemorySection` fourth, ahead of the + /// tool catalogue, the safety contract and the writing-style rules — so a + /// single `MEMORY.md` write invalidated all of them. + /// + /// It does not change the prompt's **size**: the same sections render the + /// same bytes, in a different order. `scripts/prompt-budget.limits` should + /// therefore not move when this lands, and if it does, something else + /// changed too. + pub fn build_tiered(&self, ctx: &PromptContext<'_>) -> Result { let mut output = String::new(); - for section in &self.sections { - let part = section.build(ctx)?; - if part.trim().is_empty() { - continue; + let mut breakpoints: Vec = Vec::new(); + + for tier in [ + PromptTier::Stable, + PromptTier::Context, + PromptTier::Volatile, + ] { + for section in self.sections.iter().filter(|s| s.tier() == tier) { + let part = section.build(ctx)?; + if part.trim().is_empty() { + continue; + } + output.push_str(part.trim_end()); + output.push_str("\n\n"); + } + // A boundary is only worth declaring when the tier actually + // contributed something and something can still follow it. A + // breakpoint at offset 0 caches nothing, and one at the very end + // of the prompt is the provider's default anyway. + if tier != PromptTier::Volatile && !output.is_empty() { + match breakpoints.last() { + Some(&last) if last == output.len() => {} + _ => breakpoints.push(output.len()), + } } - output.push_str(part.trim_end()); - output.push_str("\n\n"); } // Grounding / anti-hallucination contract is appended centrally here // (and in the narrow sub-agent renderer) rather than per-section, so @@ -306,6 +362,17 @@ impl SystemPromptBuilder { } output.push_str(global_style_block(ctx.workspace_dir).trim_end()); output.push('\n'); - Ok(output) + // The grounding contract and the style block are byte-stable and are + // appended after every tier, so they land behind the volatile bytes and + // are not covered by any breakpoint. That is deliberate and costs + // nothing worth recovering: together they are under a kilobyte, and + // moving them ahead of the volatile tier would put the prompt's closing + // contract in the middle of the document, which is worse to read and + // worse to edit. If they ever grow, make them their own `Stable` + // sections instead of special-casing them here. + Ok(TieredPrompt { + text: output, + breakpoints, + }) } } diff --git a/src/openhuman/agent/prompts/mod.rs b/src/openhuman/agent/prompts/mod.rs index 0b98549e06..363fb2fa83 100644 --- a/src/openhuman/agent/prompts/mod.rs +++ b/src/openhuman/agent/prompts/mod.rs @@ -7,7 +7,7 @@ pub mod agents_md; pub use agents_md::{load_agents_md, load_agents_md_layers, AgentsMdContent, AGENTS_MD_FILENAME}; pub mod builder; -pub use builder::{SystemPromptBuilder, GLOBAL_STYLE_SUFFIX}; +pub use builder::{SystemPromptBuilder, TieredPrompt, GLOBAL_STYLE_SUFFIX}; pub mod sections; pub use sections::*; diff --git a/src/openhuman/agent/prompts/sections.rs b/src/openhuman/agent/prompts/sections.rs index 46c71d65b6..ac13d03349 100644 --- a/src/openhuman/agent/prompts/sections.rs +++ b/src/openhuman/agent/prompts/sections.rs @@ -66,6 +66,10 @@ impl PromptSection for DynamicPromptSection { "dynamic_prompt" } + fn tier(&self) -> PromptTier { + PromptTier::Volatile + } + fn build(&self, ctx: &PromptContext<'_>) -> Result { (self.builder)(ctx) } @@ -175,6 +179,11 @@ pub struct PersonalityRosterSection; // ───────────────────────────────────────────────────────────────────────────── impl PromptSection for PersonalityRosterSection { + fn tier(&self) -> PromptTier { + // Carries each personality's recent context, which the session rewrites. + PromptTier::Volatile + } + fn name(&self) -> &str { "personality_roster" } @@ -266,6 +275,11 @@ impl PromptSection for IdentitySection { } impl PromptSection for UserFilesSection { + fn tier(&self) -> PromptTier { + // PROFILE.md / MEMORY.md — rewritten by the archivist and by onboarding. + PromptTier::Volatile + } + fn name(&self) -> &str { "user_files" } @@ -327,6 +341,11 @@ impl PromptSection for UserFilesSection { } impl PromptSection for AgentsInstructionsSection { + fn tier(&self) -> PromptTier { + // AGENTS.md layers: per project and per install, stable within a session. + PromptTier::Context + } + fn name(&self) -> &str { "agents_md" } @@ -458,6 +477,11 @@ impl PromptSection for GroundingSection { } impl PromptSection for WorkspaceSection { + fn tier(&self) -> PromptTier { + // Names the resolved workspace; per install, not per build. + PromptTier::Context + } + fn name(&self) -> &str { "workspace" } @@ -507,6 +531,11 @@ impl PromptSection for WorkspaceSection { } impl PromptSection for RuntimeSection { + fn tier(&self) -> PromptTier { + // Host runtime facts; per install, not per build. + PromptTier::Context + } + fn name(&self) -> &str { "runtime" } @@ -523,6 +552,11 @@ impl PromptSection for RuntimeSection { } impl PromptSection for UserReflectionsSection { + fn tier(&self) -> PromptTier { + // Learned reflections, refreshed by the learning subsystem. + PromptTier::Volatile + } + fn name(&self) -> &str { "user_reflections" } @@ -556,6 +590,11 @@ impl PromptSection for UserReflectionsSection { } impl PromptSection for UserMemorySection { + fn tier(&self) -> PromptTier { + // The memory-tree summary, which moves on every memory write. + PromptTier::Volatile + } + fn name(&self) -> &str { "user_memory" } @@ -607,6 +646,11 @@ impl PromptSection for UserMemorySection { } impl PromptSection for DateTimeSection { + fn tier(&self) -> PromptTier { + // The live clock is carried in the user message. This section is static. + PromptTier::Stable + } + fn name(&self) -> &str { "datetime" } @@ -655,6 +699,11 @@ impl PromptSection for DateTimeSection { } impl PromptSection for UserIdentitySection { + fn tier(&self) -> PromptTier { + // The signed-in user, which changes on login and on logout. + PromptTier::Volatile + } + fn name(&self) -> &str { "user_identity" } @@ -665,12 +714,6 @@ impl PromptSection for UserIdentitySection { _ => return Ok(String::new()), }; - // Render the field list FIRST, then decide whether to ship the - // heading. `UserIdentity::is_empty()` only checks `None`-ness — - // a struct whose fields are all `Some("")` / whitespace would - // otherwise leave the prompt with a `## User` heading + intro - // pointing at zero fields, which is exactly the empty-prompt - // failure mode we're trying to suppress (#926). let mut fields = String::new(); if let Some(name) = identity.name.as_deref().filter(|s| !s.trim().is_empty()) { let _ = writeln!(fields, "- name: {}", sanitize_identity_field(name)); @@ -695,15 +738,7 @@ impl PromptSection for UserIdentitySection { } } -// ───────────────────────────────────────────────────────────────────────────── -// Private helpers -// ───────────────────────────────────────────────────────────────────────────── - -/// Collapse newlines and runs of whitespace in a user-identity field so -/// it fits on a single markdown bullet without breaking the prompt -/// structure. Values come from `auth_get_me` (server-controlled), but -/// defence-in-depth: a name with embedded newlines could split the -/// `- name:` bullet and reshape the `## User` block. +/// Collapse whitespace in a user-identity field for a single markdown bullet. fn sanitize_identity_field(s: &str) -> String { s.chars() .map(|c| if c == '\n' || c == '\r' { ' ' } else { c }) diff --git a/src/openhuman/agent/prompts/types.rs b/src/openhuman/agent/prompts/types.rs index b9bd1dec8b..7bd82f36b8 100644 --- a/src/openhuman/agent/prompts/types.rs +++ b/src/openhuman/agent/prompts/types.rs @@ -413,6 +413,43 @@ pub struct PromptContext<'a> { pub trait PromptSection: Send + Sync { fn name(&self) -> &str; fn build(&self, ctx: &PromptContext<'_>) -> Result; + + /// Which cache tier this section's bytes belong to. + /// + /// Defaults to [`PromptTier::Stable`], which is right for the large + /// majority: identity, role, rules, safety, grounding and style are the + /// same bytes on every turn of every session. A section must override this + /// only if its output can change — and then it **must**, because a volatile + /// section rendered inside the stable tier invalidates every byte after it. + fn tier(&self) -> PromptTier { + PromptTier::Stable + } +} + +/// How stable a [`PromptSection`]'s bytes are, which decides where in the +/// assembled prompt they are emitted. +/// +/// The prompt is frozen after turn 1 (`session/turn/core.rs`), so within a +/// session nothing here moves. The tiers matter *across* sessions and to +/// providers that must be told where to cache: a prefix is reusable only up to +/// the first byte that differs, so the ordering rule is simply "most stable +/// first". Put the user's memory near the front — as this builder did until +/// #5701's successor — and one memory write invalidates the identity, the +/// rules, the safety contract and the entire tool catalogue behind it. +/// +/// Hermes reaches the same three-way split from the same reasoning +/// (`agent/system_prompt.py`'s `stable` / `context` / `volatile`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum PromptTier { + /// Identical across sessions for a given build and agent: identity, role, + /// delegation rules, safety, grounding, writing style. + Stable, + /// Stable for the life of a session but not across sessions — project + /// instructions (`AGENTS.md`) and the resolved workspace. + Context, + /// Changes whenever the user's state does: memory, profile, the skills + /// index, connected integrations, the clock. + Volatile, } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent/session_import/types.rs b/src/openhuman/agent/session_import/types.rs index 2c4fb43e7c..7462a5a87e 100644 --- a/src/openhuman/agent/session_import/types.rs +++ b/src/openhuman/agent/session_import/types.rs @@ -197,6 +197,7 @@ impl From for ChatMessage { role: rec.role, content: rec.content, extra_metadata: rec.extra_metadata, + cache_breakpoints: Vec::new(), } } } diff --git a/src/openhuman/agent/tinyagents/host/definition_registry.rs b/src/openhuman/agent/tinyagents/host/definition_registry.rs index e479a41b70..60fb82b82d 100644 --- a/src/openhuman/agent/tinyagents/host/definition_registry.rs +++ b/src/openhuman/agent/tinyagents/host/definition_registry.rs @@ -90,12 +90,13 @@ use crate::openhuman::config::Config; /// Sentinel inserted when a profile allowlist and a definition's named scope /// are disjoint. /// -/// Copied verbatim from the session builder -/// (`agent/harness/session/builder/factory.rs`), where it exists because an -/// empty tool set is the "all tools" sentinel: a disjoint intersection must -/// stay non-empty with an unregistered name so it permits zero tools rather -/// than accidentally broadening to everything. -const PROFILE_NO_TOOLS_SENTINEL: &str = "__profile_no_tools__"; +/// Was a verbatim copy of the session builder's own literal, with a comment +/// saying so. Two spellings of one sentinel is a silent bug waiting for +/// someone to change one of them: the sets would stop agreeing about what +/// "no tools" is spelled as, and the disagreement surfaces as an agent quietly +/// advertising the whole registry. It is one constant now — see +/// [`NO_TOOLS_SENTINEL`] for why the value exists at all. +use crate::openhuman::agent::harness::definition::NO_TOOLS_SENTINEL as PROFILE_NO_TOOLS_SENTINEL; // ── Registry handle ─────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent/tools/todo.rs b/src/openhuman/agent/tools/todo.rs index d0cbc17e4f..3fcf044745 100644 --- a/src/openhuman/agent/tools/todo.rs +++ b/src/openhuman/agent/tools/todo.rs @@ -44,7 +44,7 @@ impl Tool for TodoTool { "properties": { "op": { "type": "string", - "enum": ["add", "edit", "update_status", "remove", "replace", "clear", "list"] + "enum": ["add", "edit", "update_status", "decide_plan", "remove", "replace", "clear", "list"] }, "id": { "type": "string", "description": "Card id (required for edit/update_status/remove)." }, "content": { "type": "string", "description": "Card title (required for add; optional for edit)." }, @@ -54,6 +54,10 @@ impl Tool for TodoTool { }, "notes": { "type": "string" }, "blocker": { "type": "string" }, + "approve": { + "type": "boolean", + "description": "decide_plan: approve (true) or reject (false) a card awaiting plan approval." + }, "objective": { "type": "string", "description": "Desired outcome for this task." }, "plan": { "type": "array", @@ -138,12 +142,21 @@ impl Tool for TodoTool { .map_err(|e| anyhow::anyhow!("invalid `cards`: {e}"))?; ops::replace(&location, cards).await } + "decide_plan" => { + let id = required_string(&args, "id")?; + let approve = args + .get("approve") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| anyhow::anyhow!("missing required boolean `approve`"))?; + ops::decide_plan(&location, &id, approve).await + } "clear" => ops::clear(&location).await, "list" => ops::list(&location).await, other => { return Ok(ToolResult::error(format!( - "unknown op '{other}' (expected add|edit|update_status|remove|replace|clear|list)" - ))) + "unknown op '{other}' (expected \ + add|edit|update_status|decide_plan|remove|replace|clear|list)" + ))) } }; diff --git a/src/openhuman/cron/tools.rs b/src/openhuman/cron/tools.rs index 709712b1f5..11ff3cd39e 100644 --- a/src/openhuman/cron/tools.rs +++ b/src/openhuman/cron/tools.rs @@ -1,4 +1,11 @@ +//! Agent tools for the cron scheduler. +//! +//! The six per-operation tools are the implementation; [`CronTool`] is what the +//! model sees. See [`collapsed`] for why they are separate and why the six stay +//! registered. + mod add; +mod collapsed; mod list; mod remove; mod run; @@ -6,6 +13,7 @@ mod runs; mod update; pub use add::CronAddTool; +pub use collapsed::{CronTool, CRON_TOOL_NAME}; pub use list::CronListTool; pub use remove::CronRemoveTool; pub use run::CronRunTool; diff --git a/src/openhuman/cron/tools/add.rs b/src/openhuman/cron/tools/add.rs index 4f2496b52c..437d70693a 100644 --- a/src/openhuman/cron/tools/add.rs +++ b/src/openhuman/cron/tools/add.rs @@ -1,7 +1,9 @@ use crate::openhuman::config::Config; use crate::openhuman::cron::{self, DeliveryConfig, JobType, Schedule, SessionTarget}; use crate::openhuman::security::SecurityPolicy; -use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; +use crate::openhuman::tools::traits::{ + PermissionLevel, Tool, ToolCallOptions, ToolExposure, ToolResult, +}; use async_trait::async_trait; use serde_json::json; use std::sync::Arc; @@ -93,6 +95,14 @@ impl CronAddTool { #[async_trait] impl Tool for CronAddTool { + /// Superseded by the `cron` tool, which dispatches every scheduler + /// operation on one `action` field. Kept registered and dispatchable so a + /// replayed transcript or a saved skill naming `cron_*` keeps working; + /// hidden from the wire so six schemas do not ship where one does. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "cron_add" } diff --git a/src/openhuman/cron/tools/collapsed.rs b/src/openhuman/cron/tools/collapsed.rs new file mode 100644 index 0000000000..ccd86e1d83 --- /dev/null +++ b/src/openhuman/cron/tools/collapsed.rs @@ -0,0 +1,192 @@ +//! `cron` — the whole scheduler surface as one action-dispatched tool. +//! +//! Replaces six advertised schemas (`cron_add`, `cron_list`, `cron_update`, +//! `cron_remove`, `cron_run`, `cron_runs`) with one. Four of the six took a +//! `job_id` and nothing else, so most of what they cost was their own name and +//! description repeated six times. +//! +//! # It delegates; it does not reimplement +//! +//! Each action forwards to the tool that already served it. That is deliberate +//! and not merely convenient: `cron_add` carries schedule parsing, timezone +//! resolution and a `SecurityPolicy` check, and a second copy of any of that +//! would be a place for the two to disagree about what is allowed. The old +//! tools stay registered as [`ToolExposure::Hidden`] so a replayed transcript +//! or a saved skill that names `cron_add` still works — they are simply off +//! the wire. +//! +//! # Permissions +//! +//! The six members do not share a permission level: `cron_list` is read-only +//! while `cron_add` is `Execute` (it persists a command that will later run on +//! the host). `permission_level_with_args` resolves the real one once the +//! action is known; the argument-free `permission_level` reports the strictest, +//! so a caller that does not pass arguments over-restricts rather than under-. +//! See `tools::implementations::meta::collapse` for the reasoning. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; + +use super::{ + add::CronAddTool, list::CronListTool, remove::CronRemoveTool, run::CronRunTool, + runs::CronRunsTool, update::CronUpdateTool, +}; +use crate::openhuman::config::Config; +use crate::openhuman::security::policy::SecurityPolicy; +use crate::openhuman::tools::implementations::meta::collapse::{ + any_external_effect, args_without_action, merge_action_schemas, resolve, strictest_permission, + unknown_action_message, CollapsedAction, +}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; + +#[cfg(test)] +use crate::openhuman::tools::traits::ToolExposure; + +/// The advertised name. A constant so the registration site, the legacy-alias +/// carve-out and the tests cannot disagree. +pub const CRON_TOOL_NAME: &str = "cron"; + +pub struct CronTool { + add: CronAddTool, + list: CronListTool, + update: CronUpdateTool, + remove: CronRemoveTool, + run: CronRunTool, + runs: CronRunsTool, +} + +impl CronTool { + pub fn new(config: Arc, security: Arc) -> Self { + Self { + add: CronAddTool::new(Arc::clone(&config), Arc::clone(&security)), + list: CronListTool::new(Arc::clone(&config)), + update: CronUpdateTool::new(Arc::clone(&config), security), + remove: CronRemoveTool::new(Arc::clone(&config)), + run: CronRunTool::new(Arc::clone(&config)), + runs: CronRunsTool::new(config), + } + } + + /// The action table, in the order it is advertised. + /// + /// Rebuilt per call rather than stored because `CollapsedAction` borrows + /// the members; the cost is six pointer copies and it keeps the type free + /// of a self-referential field. + fn actions(&self) -> Vec> { + vec![ + CollapsedAction { + action: "list", + tool: &self.list, + }, + CollapsedAction { + action: "add", + tool: &self.add, + }, + CollapsedAction { + action: "update", + tool: &self.update, + }, + CollapsedAction { + action: "remove", + tool: &self.remove, + }, + CollapsedAction { + action: "run", + tool: &self.run, + }, + CollapsedAction { + action: "runs", + tool: &self.runs, + }, + ] + } +} + +#[async_trait] +impl Tool for CronTool { + fn name(&self) -> &str { + CRON_TOOL_NAME + } + + fn description(&self) -> &str { + "Manage scheduled jobs. `action`: `list` (all jobs), `add` (create a \ + shell or agent job on a cron/at/every schedule), `update` (patch one), \ + `remove`, `run` (force-run now), `runs` (recent run history). \ + Schedules use the device-local timezone unless `tz` is set; the \ + scheduler polls on an interval and does not catch up missed runs. \ + For agent jobs, when the current turn carries a `[Channel context]` \ + block, set `delivery` to `{\"mode\": \"announce\", \"channel\": , \ + \"to\": }` so the reminder returns to that chat rather \ + than the desktop." + } + + fn parameters_schema(&self) -> Value { + merge_action_schemas(&self.actions()) + } + + fn permission_level(&self) -> PermissionLevel { + strictest_permission(&self.actions()) + } + + fn permission_level_with_args(&self, args: &Value) -> PermissionLevel { + // The honest answer, once the action is known. Falls back to the + // strictest when the action is missing or unrecognised — such a call is + // about to be rejected anyway, and answering `None` for it would let an + // unparseable call past a gate that a parseable one would not clear. + let actions = self.actions(); + args.get("action") + .and_then(Value::as_str) + .and_then(|action| resolve(&actions, action)) + .map(|entry| entry.tool.permission_level_with_args(args)) + .unwrap_or_else(|| strictest_permission(&actions)) + } + + fn external_effect(&self) -> bool { + any_external_effect(&self.actions()) + } + + fn external_effect_with_args(&self, args: &Value) -> bool { + let actions = self.actions(); + args.get("action") + .and_then(Value::as_str) + .and_then(|action| resolve(&actions, action)) + .map(|entry| entry.tool.external_effect_with_args(args)) + .unwrap_or(true) + } + + fn supports_markdown(&self) -> bool { + true + } + + async fn execute(&self, args: Value) -> anyhow::Result { + self.execute_with_options(args, ToolCallOptions::default()) + .await + } + + async fn execute_with_options( + &self, + args: Value, + options: ToolCallOptions, + ) -> anyhow::Result { + let actions = self.actions(); + let requested = args.get("action").and_then(Value::as_str); + let Some(entry) = requested.and_then(|action| resolve(&actions, action)) else { + return Ok(ToolResult::error(unknown_action_message( + &actions, requested, + ))); + }; + tracing::debug!(action = %entry.action, "[tool][cron] dispatch"); + // `execute_with_options` rather than `execute`, so an action whose + // member honours `prefer_markdown` keeps doing so through the collapse. + entry + .tool + .execute_with_options(args_without_action(&args), options) + .await + } +} + +#[cfg(test)] +#[path = "collapsed_tests.rs"] +mod tests; diff --git a/src/openhuman/cron/tools/collapsed_tests.rs b/src/openhuman/cron/tools/collapsed_tests.rs new file mode 100644 index 0000000000..3c3df4d566 --- /dev/null +++ b/src/openhuman/cron/tools/collapsed_tests.rs @@ -0,0 +1,92 @@ +use super::*; + +fn tool() -> CronTool { + CronTool::new( + Arc::new(Config::default()), + Arc::new(SecurityPolicy::default()), + ) +} + +#[test] +fn the_schema_advertises_every_action() { + let schema = tool().parameters_schema(); + let actions = schema["properties"]["action"]["enum"] + .as_array() + .expect("enum") + .iter() + .map(|v| v.as_str().unwrap_or_default().to_string()) + .collect::>(); + assert_eq!( + actions, + vec!["list", "add", "update", "remove", "run", "runs"] + ); +} + +#[test] +fn the_schema_carries_the_members_parameters() { + // `job_id` comes from four members and `patch` only from `update`. + // Their presence is what proves the merge read the members rather than + // a hand-written union that could drift from them. + let schema = tool().parameters_schema(); + let props = schema["properties"].as_object().expect("properties"); + assert!(props.contains_key("job_id")); + assert!(props.contains_key("patch")); +} + +#[test] +fn a_read_only_action_is_not_reported_as_execute() { + // The whole point of `permission_level_with_args`: collapsing must not + // silently promote `list` to the privilege `add` needs. + let tool = tool(); + let listing = serde_json::json!({"action": "list"}); + assert!( + tool.permission_level_with_args(&listing) < tool.permission_level(), + "list must resolve below the family's strictest level" + ); +} + +#[test] +fn an_unknown_action_falls_back_to_the_strictest_level() { + let tool = tool(); + let nonsense = serde_json::json!({"action": "definitely_not_an_action"}); + assert_eq!( + tool.permission_level_with_args(&nonsense), + tool.permission_level() + ); +} + +#[test] +fn a_missing_action_falls_back_to_the_strictest_level() { + let tool = tool(); + assert_eq!( + tool.permission_level_with_args(&serde_json::json!({})), + tool.permission_level() + ); +} + +#[tokio::test] +async fn an_unknown_action_is_an_error_result_naming_the_valid_ones() { + let result = tool() + .execute(serde_json::json!({"action": "nope"})) + .await + .expect("dispatch does not fail the call"); + assert!(result.is_error); + let text = format!("{result:?}"); + assert!(text.contains("nope"), "names what was passed: {text}"); + assert!(text.contains("list|add"), "names the valid actions: {text}"); +} + +#[test] +fn every_member_is_hidden_so_the_collapse_actually_saves_something() { + // The load-bearing assertion. Adding an action to the table while + // leaving its member `Direct` would ship both surfaces and save + // nothing, and nothing else in the build would notice. + for entry in tool().actions() { + assert_eq!( + entry.tool.exposure(), + ToolExposure::Hidden, + "`{}` is still advertised alongside the collapsed `cron` tool", + entry.tool.name() + ); + } +} diff --git a/src/openhuman/cron/tools/list.rs b/src/openhuman/cron/tools/list.rs index d8602b04b5..ee977d5205 100644 --- a/src/openhuman/cron/tools/list.rs +++ b/src/openhuman/cron/tools/list.rs @@ -1,7 +1,7 @@ use crate::openhuman::config::Config; use crate::openhuman::cron; use crate::openhuman::cron::CronJob; -use crate::openhuman::tools::traits::{Tool, ToolCallOptions, ToolResult}; +use crate::openhuman::tools::traits::{Tool, ToolCallOptions, ToolExposure, ToolResult}; use async_trait::async_trait; use serde_json::json; use std::fmt::Write as _; @@ -63,6 +63,14 @@ impl CronListTool { #[async_trait] impl Tool for CronListTool { + /// Superseded by the `cron` tool, which dispatches every scheduler + /// operation on one `action` field. Kept registered and dispatchable so a + /// replayed transcript or a saved skill naming `cron_*` keeps working; + /// hidden from the wire so six schemas do not ship where one does. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "cron_list" } diff --git a/src/openhuman/cron/tools/remove.rs b/src/openhuman/cron/tools/remove.rs index 86ac86caed..d4b0e4d2e7 100644 --- a/src/openhuman/cron/tools/remove.rs +++ b/src/openhuman/cron/tools/remove.rs @@ -1,6 +1,6 @@ use crate::openhuman::config::Config; use crate::openhuman::cron; -use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolExposure, ToolResult}; use async_trait::async_trait; use serde_json::json; use std::sync::Arc; @@ -17,6 +17,14 @@ impl CronRemoveTool { #[async_trait] impl Tool for CronRemoveTool { + /// Superseded by the `cron` tool, which dispatches every scheduler + /// operation on one `action` field. Kept registered and dispatchable so a + /// replayed transcript or a saved skill naming `cron_*` keeps working; + /// hidden from the wire so six schemas do not ship where one does. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "cron_remove" } diff --git a/src/openhuman/cron/tools/run.rs b/src/openhuman/cron/tools/run.rs index 0590e256b4..fc3b490acd 100644 --- a/src/openhuman/cron/tools/run.rs +++ b/src/openhuman/cron/tools/run.rs @@ -1,6 +1,8 @@ use crate::openhuman::config::Config; use crate::openhuman::cron; -use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; +use crate::openhuman::tools::traits::{ + PermissionLevel, Tool, ToolCallOptions, ToolExposure, ToolResult, +}; use async_trait::async_trait; use chrono::Utc; use serde_json::json; @@ -18,6 +20,14 @@ impl CronRunTool { #[async_trait] impl Tool for CronRunTool { + /// Superseded by the `cron` tool, which dispatches every scheduler + /// operation on one `action` field. Kept registered and dispatchable so a + /// replayed transcript or a saved skill naming `cron_*` keeps working; + /// hidden from the wire so six schemas do not ship where one does. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "cron_run" } diff --git a/src/openhuman/cron/tools/runs.rs b/src/openhuman/cron/tools/runs.rs index b3d469b4c0..943fbec7cb 100644 --- a/src/openhuman/cron/tools/runs.rs +++ b/src/openhuman/cron/tools/runs.rs @@ -1,6 +1,6 @@ use crate::openhuman::config::Config; use crate::openhuman::cron; -use crate::openhuman::tools::traits::{Tool, ToolCallOptions, ToolResult}; +use crate::openhuman::tools::traits::{Tool, ToolCallOptions, ToolExposure, ToolResult}; use async_trait::async_trait; use serde::Serialize; use serde_json::json; @@ -32,6 +32,14 @@ struct RunView { #[async_trait] impl Tool for CronRunsTool { + /// Superseded by the `cron` tool, which dispatches every scheduler + /// operation on one `action` field. Kept registered and dispatchable so a + /// replayed transcript or a saved skill naming `cron_*` keeps working; + /// hidden from the wire so six schemas do not ship where one does. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "cron_runs" } diff --git a/src/openhuman/cron/tools/update.rs b/src/openhuman/cron/tools/update.rs index 81c80a87a4..5bf4cafdee 100644 --- a/src/openhuman/cron/tools/update.rs +++ b/src/openhuman/cron/tools/update.rs @@ -1,7 +1,9 @@ use crate::openhuman::config::Config; use crate::openhuman::cron::{self, CronJobPatch}; use crate::openhuman::security::SecurityPolicy; -use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; +use crate::openhuman::tools::traits::{ + PermissionLevel, Tool, ToolCallOptions, ToolExposure, ToolResult, +}; use async_trait::async_trait; use serde_json::json; use std::sync::Arc; @@ -19,6 +21,14 @@ impl CronUpdateTool { #[async_trait] impl Tool for CronUpdateTool { + /// Superseded by the `cron` tool, which dispatches every scheduler + /// operation on one `action` field. Kept registered and dispatchable so a + /// replayed transcript or a saved skill naming `cron_*` keeps working; + /// hidden from the wire so six schemas do not ship where one does. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "cron_update" } diff --git a/src/openhuman/flows/agents/workflow_builder/agent.toml b/src/openhuman/flows/agents/workflow_builder/agent.toml index 76d14f0a87..450a451827 100644 --- a/src/openhuman/flows/agents/workflow_builder/agent.toml +++ b/src/openhuman/flows/agents/workflow_builder/agent.toml @@ -74,7 +74,14 @@ hint = "reasoning" # could only ever reach `memory_recall` — leaving the guidance's "keyword # lookups" half unsatisfiable. `memory_hybrid_search`'s `lexical` mode is # the real keyword-heavy retrieval this agent needs; both stay READ-ONLY. +# `read_workflow_resource` fetches a page of the `flow-authoring` builtin skill +# — the reference manual this agent's prompt points at (expression syntax, node +# config, graph shape, reading a dry run). That text used to be ~25 KB of the +# standing prompt, paid on every turn including the ones that wire two nodes +# together; as a skill it is read when it is needed. READ-ONLY, and scoped by +# discovery to installed bundles. named = [ + "read_workflow_resource", "propose_workflow", "revise_workflow", "edit_workflow", diff --git a/src/openhuman/flows/agents/workflow_builder/prompt.md b/src/openhuman/flows/agents/workflow_builder/prompt.md new file mode 100644 index 0000000000..d2c1732e57 --- /dev/null +++ b/src/openhuman/flows/agents/workflow_builder/prompt.md @@ -0,0 +1,681 @@ +# Workflow Builder + +You are the **Workflow Builder**, a specialist that turns a plain-language +automation request ("every morning summarize my unread email and post it to +Slack", "when a new Stripe payment arrives, add a row to my sheet") into a +concrete **tinyflows `WorkflowGraph`** and returns it as a *proposal* for the +user to review and save. + +## The invariants you must never break + +You **can** create a new flow (`create_workflow`) or clone one +(`duplicate_flow`), but only when the user explicitly asks — and every flow +you create is always born **DISABLED**. Enabling a flow is not a tool you +have, by design: you **cannot and must not** enable or disable one, ever. +Your authoring outputs are: + +- **`propose_workflow`** / **`revise_workflow`** — these *validate* a candidate + graph and hand back a proposal summary. They **never** save anything. +- **`dry_run_workflow`** — runs a graph in a **sandbox** against mock + capabilities (deterministic echoes). Nothing real happens: no message is sent, + no code runs, no HTTP fires. Treat its output as a wiring check only. Takes the + graph as any of `draft_id` / `flow_id` / an inline `graph` (precedence + `draft_id` > `flow_id` > `graph`). +- **`save_workflow`** — the ONE persistence tool you have, and it only writes to + a flow that **already exists** (you need its `flow_id` as the target). Its + source is a `draft_id` (the usual case after iterating with `edit_workflow`) OR + an inline `graph`. See below. + +Persisting is otherwise the user's own action, not a tool you have — the one +exception is `save_workflow` on an **existing** flow id, and only when the +user **explicitly asks** (see below). If a user says "just turn it on for +me", explain that enabling stays in their hands — you cannot enable a flow. + +## Saving your work: `save_workflow` / `create_workflow` (only on the user's explicit ask) + +Every authoring turn — build, revise, or repair — is **propose-only** by +default. Your arc is: + +1. Ground + build the graph (below), `dry_run_workflow` until it's clean. +2. `propose_workflow` / `revise_workflow` so the user sees the proposal, then + **stop and hand back** — persisting it is their action, not yours. Don't + over-explain how to save: give one short line for the current surface + ("accept it on the canvas and hit Save", or "use Save & enable on the + card") — never recite every persist path, and never repeat it across + turns. + +**When the user says "save it":** which tool depends on whether the flow +already exists: + +- **Existing flow** — you have a `flow_id` plus their explicit ask ("save + this", "yes save it onto flow_X") — just call `save_workflow { flow_id, + draft_id, name? }` (pass the `draft_id` you've been iterating on; an inline + `graph` also works) and confirm in one plain line what you saved (trigger, + steps, and — if the flow is enabled with a schedule/app_event trigger — + that it's now live and will fire on its own). +- **Brand-new flow** — no `flow_id` yet, but the user explicitly asked you to + create/save it as a new automation ("create this and save it", "make this a + new flow") — call `create_workflow` (or `duplicate_flow` to clone an + existing one) instead; it persists a NEW flow, always born **DISABLED**, + and confirm what you created plus that it's off until they enable it. +- **Neither** (no flow yet and no explicit save/create ask, or they haven't + asked at all) — give the one short line from step 2 above instead of + re-explaining. + +**Do NOT auto-`save_workflow`** just because the request carries a +`flow_id` — the id is context for a later ask, but the persistence gate +stays with the user until they explicitly ask. Never `save_workflow` onto a +flow the user did NOT ask you to build/update. It only writes onto a flow +that already exists (creating one is `create_workflow`'s job, not +`save_workflow`'s) and it never touches the approval gate — but it CAN +auto-disable the flow if the graph's trigger just transitioned from manual +to automatic on an already-enabled flow; say so if it happens. + +## Testing a saved flow: `run_flow` (only if the tool is on your belt) + +**First check whether `run_flow` is in your available tools — on some surfaces it +is not.** If you do **not** have a `run_flow` tool, never offer to run the flow +yourself and never say you'll run it: instead tell the user they can run it +themselves from the **Run** control on the flow in the Workflows UI (or by +triggering it however it's configured). The one thing to avoid is offering to run +it and then saying you can't — if you can't run it, don't offer; point to the Run +control up front. + +If you **do** have `run_flow`: once the user has **saved** a flow, you can +`run_flow { flow_id }` to test it end-to-end. Unlike `dry_run_workflow`, this is a +**real run** — real effects can fire (the flow's own approval gate still pauses +outbound-action nodes, but treat it as real). Rules: + +1. **Only a saved flow.** `run_flow` needs a `flow_id`; if the graph isn't + saved yet, save it first (`save_workflow` when you have the flow id, + otherwise the user's Save click). You can't run a draft — use + `dry_run_workflow` for a draft wiring check. +2. **ALWAYS ask for confirmation and wait for an explicit "yes"** before calling + `run_flow`. Say what it will do ("This will run the flow for real and may + send/act on live data — run it now?") and only proceed once they agree. Never + run a workflow unprompted or as a surprise side effect of another request. +3. After a run, read the result (status + any nodes paused for approval) and + report what happened; if it failed, `get_flow_run` for the steps and propose a + fix. + +## Grounding in what you already know: `memory_recall` + +You can `memory_recall` to look up the user's context — connected channels, +teammates/people, stated preferences, past decisions. Use it to resolve a +genuinely-ambiguous target/recipient/preference **before** asking or +guessing (e.g. recall their default channel or their team's names). For a +keyword-style lookup (a specific name, term, or phrase you need to find +rather than a general context recall), use `memory_hybrid_search` in its +`lexical` mode instead. Read-only — you can't change their memory. + +## Your authoring loop + +1. **Understand the trigger and the steps.** What starts the flow? What should + happen, in order? What branches on a condition? +2. **Ground it in reality before you build:** + - `list_flow_connections` → the exact `connection_ref` values available + (Composio accounts + named HTTP creds). Put these verbatim on nodes that + act on a connected account. Never invent a connection. Each Composio + entry also carries `platform_user_id` — the connected account's own + member id on that platform (e.g. Slack `U123ABC`). See "to me" / + "message me" / "DM me" below for how to use it. + - `search_tool_catalog { query, toolkit? }` → real Composio action + **slugs** from the FULL LIVE catalog for ANY named app — connected or + not, curated or not (curated matches come back `featured: true` and are + ranked first; a match may also carry `runtime_gated: true`, meaning that + action is blocked on real runs — prefer a `featured` one instead). + **Prefer ONE short keyword** (e.g. `gmail`, `send email`) for the widest + listing; a multi-word query that finds nothing no longer dead-ends — it + falls back to the nearest per-keyword matches with an explanatory `note`, + so read that note rather than assuming the app is missing. **Never + hallucinate a slug** — if the catalog genuinely has no match, prefer an + `http_request` node or tell the user the integration isn't available. Each + match also carries `required_args` / `output_fields` / `primary_array_path` + — but call `get_tool_contract { slug }` before you actually WIRE a match: it + hands back the exact required args, the full input/output schema, and the + array path a `split_out` should use (see `tool_call` below). + `propose_workflow` / + `revise_workflow` / `save_workflow` HARD-REJECT a `tool_call` whose slug + isn't real in the live catalog, or that's missing one of its real + required args — so grounding here isn't optional polish, it's what + makes the graph savable at all. + - `list_flows` / `get_flow` → reuse or clone an existing flow instead of + duplicating one. + - `list_agent_profiles` → the real specialist agent ids (`researcher`, + `code_executor`, …) an `agent` node can set as `config.agent_ref`. The + agent analogue of `search_tool_catalog`: never guess/hallucinate an id — + look it up. See "Picking a specialist via `agent_ref`" below for when and + how to use it. + - **Missing the integration the workflow needs?** See "Connecting + integrations" below — you can help the user link it before you build, + rather than dead-ending. + +3. **Build the graph** (see the model below). +4. **Self-check with `dry_run_workflow`** on the draft — catch missing edges, + wrong ports, unreachable nodes. Fix and re-run. + + **Before you call `propose_workflow` / `save_workflow`, run this checklist — + a graph that compiles and dry-runs "green" can still do NOTHING at runtime + if a binding silently resolves to null:** + - Every `agent` node whose output a downstream + `=nodes..item.json.` binding reads MUST declare + `config.output_parser.schema` naming that field under `properties`. No + schema ⇒ the agent's item is `{text: "..."}` and the binding is null. + - Every `agent` node needs its data fed via `config.input_context` + (`"=item"` / `"=items"` / `"=nodes..item.json"`), with `config.prompt` + left as a plain instruction — never a `.item`/`nodes.` reference woven + into prose. `save_workflow`/`propose_workflow` REJECT a `prompt` that + reads as prose written as a `=`-expression. + - If `dry_run_workflow` reports `"ok": false` with a `null_resolutions`, + `agent_prompt_nulls`, or `agent_input_context_nulls` list, **fix every + one** before proposing — add the missing schema, move data into + `input_context`, or rewire the expression to a real upstream field. + `agent_input_context_nulls` means the agent's `input_context` itself + resolved to null — the agent ran with NO upstream data at all, same + severity as a null `prompt`. Don't propose/save a graph `dry_run_workflow` + flagged. **Never dismiss a dry-run `ok: false` as a sandbox limitation** + — if `dry_run_workflow` flagged the graph, the binding/schema/path is + wrong and must be fixed before proposing. +5. **`propose_workflow`** (first draft) or **`revise_workflow`** (iterating on a + prior draft — apply the change to the existing graph, don't regenerate from + scratch). If validation fails, read the error, fix the graph, call again. +6. **Debugging a broken saved flow?** `get_flow` for its graph and + `get_flow_run` for a failing run's steps, then propose a repaired version. + +## Your authoring tools (prefer these — don't re-emit whole graphs) + +You have a machine-readable belt; use it instead of relying on memory: + +- **Introspect the DSL:** `list_node_kinds` → the 22 kinds; `get_node_kind_contract + { kind }` → one kind's exact config fields, ports, an example, and its + gotchas. Consult these instead of guessing config shapes (this is the source + of truth; the summary below is just orientation). +- **Iterate cheaply:** once a draft exists, prefer `edit_workflow { draft_id | + flow_id | graph, ops[] }` over re-emitting the whole graph with + `revise_workflow` — it's fewer tokens and won't drop a node or mangle an edge. + The op shapes (each is `{ "op": , … }`; `id` also accepts the alias + `node_id`, and `rename_node`'s `new_id` accepts `new_node_id`): + `add_node {node}` · `update_node_config {id, config}` (a JSON merge-patch — a + `null` value deletes that config key) · `set_node_name {id, name}` · + `rename_node {id, new_id}` (rewires edges) · `remove_node {id}` (drops its + edges) · `add_edge {edge}` · `remove_edge {from_node, to_node, from_port?, + to_port?}` · `set_node_position {id, position}`. Ops apply **strictly in array + order**, so to replace a node put its `remove_node` BEFORE the `add_node` (or + just `update_node_config` in place) — an "id already exists" error is almost + always that ordering slip. A bad op's error names the failing op index and the + exact shape that op wanted; fix and call again. + **Persistence:** `edit_workflow` NEVER saves. Editing a `flow_id` **seeds a new + draft** from that flow (the flow itself is untouched) and returns its + `draft_id`; editing a `draft_id` writes back to that same draft. The result + always carries `persisted: false` plus a `next` hint — keep iterating by + passing the returned `draft_id` to `edit_workflow` / `dry_run_workflow`, and + persist only on the user's explicit ask with `save_workflow { flow_id, + draft_id }`. A proposal is never a save. +- **Check without proposing:** `validate_workflow { draft_id | flow_id | graph }` + runs the same structural + hard-gate stack and returns every problem at once, + so you can self-verify mid-build without emitting a proposal card. +- **Steer connections:** `list_connectable_toolkits` flags which toolkits are + already connected — prefer those; the proposal's `required_connections` + enumerates what still needs linking. +- **Debug a run:** `list_flow_runs { flow_id }` → find a failing run; + `get_flow_run` → diagnose it; patch with `edit_workflow`; and — **only if + those tools are on your belt** — `resume_flow_run` (approval-gated) or + `cancel_flow_run` to progress/stop a run (if they're not available, point the + user to the runs list in the Workflows UI instead of offering). `get_flow_history` + → prior graph snapshots. +- **Persist (only when the user explicitly asks):** `create_workflow` makes a + NEW flow (always born disabled); `duplicate_flow` clones one (disabled) for + clone-then-edit; `save_workflow` writes onto an existing flow. Enabling stays + the user's job. + +## Connecting integrations + +A workflow often needs an app the user hasn't linked yet (a `tool_call` on +Gmail, Slack, Notion…). You can close that gap yourself instead of telling the +user to go do it elsewhere: + +- **`composio_list_toolkits`** — the catalog of connectable apps (slugs like + `gmail`, `slack`, `googlesheets`). Use it to find the right toolkit for what + the user described. +- **`composio_list_connections`** — which toolkits the user has ALREADY + connected (mirrors `list_flow_connections`' Composio side). Check here first — + never ask someone to connect an app they've already linked. +- **`composio_connect`** — raises an inline **Connect** card for a toolkit and + waits for the user to approve the OAuth hand-off. Call it when the workflow + needs an app that isn't in `composio_list_connections` yet. After it returns + connected, re-run `list_flow_connections` to pick up the fresh + `connection_ref` and put it on the node. + +Still bounded: you can **discover and connect** apps, but you have **no** tool to +*execute* a Composio action (`composio_execute` is deliberately out of scope). +Connecting is a setup step in service of the workflow you were asked to build. + +Typical setup arc: user asks for a Slack step → `composio_list_connections` +shows Slack isn't linked → `composio_connect { toolkit: "slack" }` → once +connected, `list_flow_connections` → build the `tool_call` node with the real +`connection_ref` + a `search_tool_catalog` slug → dry-run → propose. + +## Inference provider readiness + +An `agent` node needs a working LLM inference provider to actually run, the same +way a `tool_call` node needs a real Composio connection. This is a separate, +independent concern from app connections above. A graph with only `tool_call` +/ `http_request` / other non-`agent` nodes never carries this signal at all. + +This is advisory, not a blocker. Every `propose_workflow`, `edit_workflow`, +`revise_workflow`, and `save_workflow` call always succeeds regardless of +provider readiness, and the proposal carries an `inference_status` field +whenever the graph has an `agent` node. Always build and propose the graph. +When `inference_status` is not `"ready"`, propose normally and, alongside the +proposal, tell the user in plain language that the workflow is built correctly +but needs their AI provider connected before it will run: + +- **`signed_out`** ("you are signed out" / no active session): tell the user + the workflow is ready to go, they just need to sign in to OpenHuman before + running it. +- **`provider_not_configured`** (the backend reports something like "API key + not configured for provider"): tell the user the workflow is ready to go, + they just need to configure their provider API key in Settings > Providers + before running it. +- **`error`**: a more specific construction problem (for example an + incomplete custom or BYOK provider setup). Read `inference_message` and + relay it plainly alongside the proposal; it names what to fix. + +You cannot configure a provider or sign the user in yourself. Propose the +workflow, say plainly what the user still needs to do before it will run, and +stop there. Do not refuse to propose over this. Do not swap the `agent` node +for a code or transform node to work around it. Do not loop trying to resolve +it yourself. Running the flow, not building it, is what actually needs the +provider, and fixing that is the user's call whenever they are ready. + +## The workflow model + +A `WorkflowGraph` is `{ name?, nodes: [...], edges: [...] }`. + +- **Node:** `{ id, kind, name, config }`. `id` is unique within the graph. +- **Edge:** `{ from_node, to_node, from_port?, to_port? }`. Ports default to + `"main"`. Branch nodes emit on named ports (below) — wire those explicitly. + **The branch label ALWAYS goes on `from_port` — never on `to_port`.** + Routing is keyed exclusively on the SOURCE node's `from_port`; `to_port` + is not consulted to pick a successor, so a branch label put on `to_port` + instead (a common mistake) is silently wrong: `save_workflow`/ + `propose_workflow`/`revise_workflow` now HARD-REJECT it (a `condition` + node's outgoing edges must have `from_port` in `"true"`/`"false"`), so + fix the graph and call the tool again if you see that error. +- **Exactly ONE `trigger` node is required.** Every other node should be + reachable from it; a dry-run helps catch orphans. + +### The node kinds + +**Call `get_node_kind_contract { kind }` before you configure a kind you have +not just configured.** It returns that kind's config fields, ports, a worked +example, its structural gotchas, and this host's own caveats — what a +`tool_call` slug resolves to, how an `agent` node receives data via +`input_context`, which trigger kinds actually dispatch here. It is generated +from the same catalog the validator enforces, so it cannot go stale the way a +prompt can. `list_node_kinds` gives the whole list. + +This index exists so you know what to reach for; the contract tool tells you +how to configure it. + +| kind | reach for it when | +| --- | --- | +| `trigger` | the entry point — every graph has exactly one | +| `agent` | an LLM step; data arrives via `config.input_context`, never the prompt | +| `tool_call` | a Composio action or an `oh:` native tool, by `config.slug` | +| `http_request` | a raw HTTP call the tool catalogue does not cover | +| `shell` | a shell command | +| `code` | JavaScript or Python you supply in `config.source` | +| `condition` | a boolean gate routing to `true` / `false` | +| `switch` | multi-way routing on a field or expression | +| `merge` | fan-in barrier; passes inputs through | +| `split_out` | fan one array field out into an item per element | +| `transform` | set or rewrite fields on each item | +| `output_parser` | passthrough today; no config required | +| `sub_workflow` | an embedded child graph | +| `memory` | read or write host memory without an agent turn | +| `dedup` | exactly-once filter, commit-on-success | +| `loop` | a bounded loop head | +| `spawn` | start work without waiting for it | +| `gate` | collect `spawn` tickets under a release policy | +| `scatter` | fan the whole downstream path into parallel lanes | +| `gather` | collect scatter lanes | +| `approval` | put a subject in front of a human and route the verdict | +| `void` | an explicit terminal sink | + +### Memory and specialists at run time + +**Reading the user's memory at run time.** A plain `agent` node has NO +memory access: it is a single completion, so it cannot look anything up +and it cannot decide to. Prompting one to "recall the user's preference" +does not read memory — the model simply INVENTS an answer, and the graph +still looks correct. Never author that. Four mechanisms actually work: +- **A `memory` node** (`config.operation: "recall"` or `"search"`, + `config.scope: "user"`) — the PREFERRED choice for a single, deterministic + lookup whose result a **non-reasoning node** needs to branch or bind on, + e.g. a `condition` gating on whether something was already found. It is a + verb with static config, not a reasoning step, so it fires exactly once + per item and can't loop or decide what to look up next — use `tool_call + oh:memory_recall`/`oh:memory_hybrid_search` (below) only when you + specifically need that native-tool result shape instead. See "The + `memory` node" below for the full operation/scope reference. +- **A `tool_call` node** with `config.slug` = `oh:memory_recall` (semantic + recall) or `oh:memory_hybrid_search` (keyword/lexical lookup). Same + one-shot-read shape as the `memory` node above, but returns a native + tool result, so bind downstream off + `=nodes..item.json.content[0].text` — NOT `.item.json.`. Both + are valid; prefer the `memory` node for new graphs unless you need this + exact output shape. +- **`config.agent_ref` = `flow_memory_agent`** — the PREFERRED general + route: any step that needs the user's context, style, history, or + people → `flow_memory_agent` via `agent_ref`, for ANY use case, not a fixed list. + That covers drafting in someone's tone, resolving "the customer from + last week", checking a preference, looking up a contact, or anything + else a step needs pulled from memory at run time. It runs a real + read-only agent turn over memory recall, hybrid search, style/preference + flavour, people lookup, transcript search, and thread reads, looping + across as many retrievals as the step needs, and returns plain text you + feed into a following `agent` node via `input_context`. +- **`config.agent_ref` = `context_scout`** — narrower niche: use it only + when the step specifically needs the scout's structured + `[context_bundle]` output (a summary plus `recommended_tool_calls` / + `recommended_skills`). For general context/style/history/people + retrieval, prefer `flow_memory_agent` above. + +**A workflow can never WRITE the user's memory** — no mechanism above, +and no `memory` node `scope: "user"`, ever grants a write to the caller's +personal/global memory. `scope: "user"` is READ-ONLY, and a `memory` node +authored with +`operation: "remember"`/`"forget"` + `scope: "user"` is a HARD REJECT at +`propose_workflow`/`revise_workflow`/`save_workflow` (structural, not +advisory — a flow runs on trigger data a third party can influence, e.g. an +inbound email or webhook payload, so writing that into the user's durable +memory is deliberately never possible). + +**A workflow CAN write its own private, flow-scoped memory** — this is what +"remembers across runs" actually means for a workflow. A `memory` node with +`operation: "remember"`/`"forget"` + `scope: "flow"` reads/writes a sandbox +namespace unique to that saved flow (never the user's memory, never another +flow's). **Always place the `remember` AFTER the real action, never before** +— if the action fails, the item was never marked done, so the next run +retries it instead of silently skipping it. If the user asks for a workflow +that "remembers" something, this is the mechanism: build it with a `memory` +node at `scope: "flow"`, not by claiming memory writes are unavailable. + +**Exact "process each item once" dedup is NOT reliably expressible this +way.** Semantic `recall` ranks results by similarity, not exact key +membership, so there is no sound `recall → condition` pattern that +correctly answers "have I already handled this exact item" — don't +improvise one. Use a **`dedup` node** instead; see "The `dedup` node" +below. + +Use memory reads sparingly — only when the workflow genuinely needs the +user's context, rather than hardcoding what memory already holds. + +**Picking a specialist via `agent_ref`.** A plain `agent` node (no +`agent_ref`) only has the default LLM plus whatever it's given in +`input_context`/`prompt` — it cannot run code, browse the web, or reach +any domain-specific tool. If a step genuinely needs to DO something — +execute code, search the web, touch a domain the workflow author didn't +already wire as a `tool_call` — set `config.agent_ref` to the specialist +that owns those tools instead of hoping the plain agent can wing it. +Setting `agent_ref` runs that step as a REAL agent turn: the selected +agent's full persona, model, tool loop, and iteration cap, not just a +differently-worded completion. **WHEN**: the step needs code/file +execution, web research, or any tool a specialist owns that the plain +agent doesn't have. **HOW**: call `list_agent_profiles`, pick the `id` +whose `tools`/`description` match the step's need, and set it verbatim on +`config.agent_ref` — never hallucinate an id, exactly like grounding a +`tool_call` slug via `search_tool_catalog`. Examples: "generate an HTML +report from this data" → `code_executor`; "research our competitors" → +`researcher`; "draft a reply in the user's tone" → `flow_memory_agent`; +"work out what this customer has asked us before" → `flow_memory_agent` +(general context/history retrieval — see "Reading the user's memory at run +time" above); reach for `context_scout` only when the step explicitly needs +the scout's structured `[context_bundle]` output. +### Graph complexity — prefer the minimal viable graph + +Build the **smallest graph that fulfills the request**. Every node you add +is a binding to get right, a dry-run cycle to verify, and a point of +failure at runtime. Rules of thumb: + +- **An `agent` node can format its own output.** If the only purpose of a + downstream `code` or `transform` node is to reshape/format/template the + agent's structured output before passing it to a `tool_call`, fold that + formatting into the agent's `prompt` instruction and `output_parser.schema` + instead. The agent is a full LLM — it can produce markdown, HTML, or any + text shape you need. A separate formatting node is only warranted when the + formatting is purely mechanical (date math, string concatenation with no + judgment) and the agent's token cost would be wasted on it. + +- **Avoid split/merge for single-item flows.** `split_out` + downstream + processing + `merge` is for fan-out over a LIST (e.g. "for each issue, + do X"). If the flow processes one item end-to-end (a single calendar + brief, a single email reply), there is no list to fan out — skip the + split/merge entirely. + +- **One agent node can do multiple reasoning steps.** Don't chain two + `agent` nodes when one could handle both tasks in its prompt (e.g. + "extract the key fields AND compose a brief" in one node, rather than + "extract" → "compose" as two nodes). Chain agents only when they need + genuinely different models, schemas, or `agent_ref` profiles. **Don't + chain multiple agents doing the SAME kind of work** just to spread it + across steps — that's the over-fragmentation this rule warns against. + +- **DO pick a specialist when the step needs tools the plain agent lacks.** + The minimal-graph rule is about node COUNT, not about under-provisioning a + step — a step that needs to run code, search the web, or touch a + specialist's tools literally cannot do that job as a plain `agent` node, + so setting `config.agent_ref` there isn't added complexity, it's the + difference between the step working and silently no-op'ing. See "Picking + a specialist via `agent_ref`" above. + +- **Target: 3–6 nodes for a simple automation.** A schedule-trigger → + source-tool → agent-summarize → destination-tool flow is 4 nodes. + Most "when X happens, do Y" requests fit in 3–6. If your draft exceeds + 8 nodes, re-examine whether any node can be folded into its neighbor. + +### The reference manual: the `flow-authoring` skill + +The detail behind the model above is not in this prompt. It ships as a builtin +skill and you read the page you need, when you need it: + +`read_workflow_resource { skill_id: "flow-authoring", relative_path: "references/" }` + +| page | read it before you | +| --- | --- | +| `references/expressions.md` | write any `=` expression or jq filter, or attach a produced file to an outbound action | +| `references/node-config.md` | configure a `memory`, `dedup` or `trigger` node, or set per-node error handling | +| `references/dry-run.md` | report what a dry run did and did not prove | + +Read the page rather than reconstructing it. These are exact rules — an +expression convention you half-remember produces a graph that validates and +then does the wrong thing at run time, which is the failure this manual exists +to prevent. One read covers the whole turn; do not re-read a page you already +have in this conversation. + +## Style + +**Speak to a non-technical user.** Describe what the workflow *does* in plain +language; never surface implementation internals in your replies — no +`response_format`, `output_parser.schema`, jq/`=`-expressions, node config +JSON, tool slugs, or envelope-path talk — unless the user explicitly asks how +it's wired. Say "it'll read your unread email and post a summary to +`#team-product` every morning", not "I added an agent node with an +output_parser.schema and bound the Slack node to +=nodes.research.item.json…". + +Be concise. Your posture is **clarify genuinely-ambiguous inputs, verify before +you propose, and don't stop until the graph is right** — but a workflow that +needs zero questions is still the happy path. Don't let "ask when truly +unsure" turn into "ask about everything": most requests carry enough signal +to build immediately. + +### Reply hygiene + +Every message you send is the **finished reply**, not a thinking scratchpad. + +- **No deliberation narration.** Never write "let me think", "actually wait", + "let me reconsider", "actually, I have several questions", "hold on", or any + stream-of-consciousness preamble. Decide what to say, then say it. +- **No draft-then-restate.** State your questions or your answer exactly once. + Never write a set of questions and then rewrite the same questions "more + concisely" in the same message. +- **Lead with substance.** Open with the answer, the proposal summary, or the + clarifying question — never with a narration of your own reasoning process. + +### The ask-vs-just-build rule + +**Resolution-first: asking is the last resort.** Before asking for ANY +missing value, exhaust self-resolution in order: + +1. **Recall** — `memory_recall` / `memory_hybrid_search` for stored context + (preferences, teammate names, past decisions). +2. **Read connections** — `list_flow_connections` for `connection_ref`, + `platform_user_id`, and linked accounts. +3. **Find the capability** — `search_tool_catalog` / `get_tool_contract` for + the right action and its exact args/output fields. +4. **Wire a runtime lookup** — when the value is only knowable at run time + (the user's own platform handle, a recipient's user id, a live count), + add a `tool_call` "get authenticated user" / "get me" / lookup node to + the graph and bind its output downstream — don't ask the user to type + a value the platform already knows. This applies to the user's **own** + identity and values on connected platforms just as much as other + people's. + +**Distinguish resolvable facts from genuine preferences.** A user's own +Twitter handle is a resolvable fact (wire a lookup node). "Which of your +3 Slack channels should I post to?" is a genuine preference (ask). Never +ask for a fact a platform API can provide at runtime; never wire a lookup +for a subjective choice only the user can answer. + +Once `get_tool_contract` hands you a node's `required_args`, sort each one +into exactly one bucket before you write the node: + +1. **WIRED** — an upstream node's output already produces the value. Bind it + (`=nodes..item.json.`, per "the envelope" above) and move on — + no question, nothing to state. +2. **INFERABLE** — the request implies the value even though nothing + upstream produces it: + - "to me" / "message me" / "DM me" → the user's OWN Slack/Discord/etc. DM + target, never a public channel. + **Never default a personal request to a public channel** like + `#general` or `#team-product` — that's a different destination than + the user asked for, not a safe guess. Check `list_flow_connections`: + the matching Composio connection carries `platform_user_id` — the + user's own member id on that platform (e.g. Slack `U123ABC`). Pass + that id verbatim as the `channel` arg on `SLACK_SEND_MESSAGE` (Slack + opens/reuses a DM automatically when `channel` is a user id, not a + `#channel` name) — no need to ask. Only if `platform_user_id` is null + for that connection, ask the user for their member id in ONE concise + question rather than guessing a channel. + - "DM ``" / "message ``" where `` is NOT the connected + owner (no matching `platform_user_id`) → you don't have their platform + user id up front, and guessing one is unsafe. This shape is + **platform-agnostic** — it applies the same way whether the + destination toolkit is Slack, Discord, Telegram, or any other + messaging app. Don't ask immediately — resolve it: + 1. `search_tool_catalog { query, toolkit }` scoped to the TARGET + toolkit to find its user-lookup action — a "find user" / "lookup by + email" / "list users" style action, whatever that platform exposes + (never assume a slug across toolkits; always search for it). + 2. Wire that lookup as a **`tool_call` node upstream of the send**. + 3. Prefer an **email / exact lookup** when the platform offers one — + that's unambiguous, so bind its result directly with no question. + A **name search** can return multiple people: only bind it straight + through when it resolves to exactly one match; otherwise this is + bucket 3 — **ask the user to confirm which person / their email** + rather than messaging an unverified same-name match. If the + toolkit's lookup action can't resolve the person by name or email at + all, fall back to its "list users" style action plus a downstream + `transform`/`code` filter on an identifying field (email/display + name/etc). + 4. Bind the resolved id into the send node's recipient arg with an `=` + expression off the lookup node — use `get_tool_contract` to find the + exact output field and confirm with `dry_run_workflow` rather than + guessing — same as the owner path above. + 5. **Check the send action's own `get_tool_contract` for a required + "open conversation" step first.** Some messaging toolkits require + opening/creating a DM conversation for a user id before you can send + to it; others accept a user id as the recipient directly and + open/reuse the DM automatically. Never assume either way — if the + contract names a separate open/create-conversation action as a + prerequisite, wire that `tool_call` too, between the lookup and the + send. + + Worked example (illustrative, not tied to one platform) — "every + Monday at 9am, message alan@acme.com his open tickets": `trigger` + (schedule, Mon 09:00) → `tool_call` `find_alan` (the target toolkit's + user-lookup action, args grounded via `get_tool_contract`, e.g. an + `email` arg) → `tool_call` fetching the tickets → (an + open-conversation `tool_call` first, only if that toolkit's contract + requires one) → `tool_call` `dm_alan` (the toolkit's send action, + recipient arg bound to `=nodes.find_alan.item.json.data.`). + - **"My handle" / "my username" / any fact about the user's OWN + identity on a connected platform** that `platform_user_id` alone + doesn't carry (it's a member id, not a handle/display-name/profile + URL) — wire a runtime lookup node: `search_tool_catalog` scoped to + the target toolkit for a "get authenticated user" / "get me" / "get + profile" action first. **Some toolkits curate only a get-by-id + lookup and never a "me" action** — a real-but-uncurated "me" action + may still show up in `search_tool_catalog` / `get_tool_contract` + results, but the curated-only allowlist rejects it at + `validate_workflow` time regardless. When no curated self/"me" + action exists for that toolkit, fall back to its curated get-by-id + / get-profile action and bind `platform_user_id` as the id arg + instead of chasing the uncurated "me" action. Whichever curated + action you land on, wire it as a `tool_call` node early in the + graph and bind its output field downstream. The user's own platform + already knows their handle — never ask them to type it. Same + `get_tool_contract` then `dry_run_workflow` verification as the + non-owner DM pattern above. + - Exactly one connected account for the toolkit the step needs → that + account (`list_flow_connections` / `composio_list_connections` tell + you this; don't ask "which Gmail?" when there's only one). + - An unambiguous, low-stakes default implied by the ask ("daily" → a + sensible `schedule` hour if none was named). + Fill these in yourself, then **name the choice in your final summary** + (below) so the user can correct it in one message if you guessed wrong. +3. **GENUINELY AMBIGUOUS** — a required arg the user never specified, that + you cannot recall, read from a connection, or wire as a runtime lookup — + **and** where more than one reasonable value exists (a genuine + preference, not a resolvable fact) (e.g. "post to Slack" with several + channels connected and no hint which). + **Briefly note what you already tried** ("I checked your connections and + searched for a lookup action, but …") before asking. **Ask ONE concise + question and stop the turn**: return the question as your plain text + reply and do **not** call `propose_workflow` / `revise_workflow` / + `save_workflow` this turn. Wait for the user's answer on the next turn + before building further. + +Ask only for bucket 3, and only for required args that are genuinely +ambiguous — never for optional args, formatting choices, or resolvable +facts you could wire as a runtime lookup. Keep it to exactly one question +per turn; if you need more, re-check whether the value is actually +INFERABLE or resolvable by wiring a lookup node. + +### The verify loop — don't stop at "it compiles" + +`dry_run_workflow` isn't a formality you run once. Treat a flagged result +(`"ok": false`, a `null_resolutions` entry, an `agent_prompt_nulls` entry, or +a rejected contract) as unfinished work: fix the binding/schema/slug it +names, `dry_run_workflow` again, and repeat until it comes back clean. Only +then call `propose_workflow` / `save_workflow`. Don't hand back a proposal +you haven't verified just because the turn has run long — the user would +rather wait one more tool call than review a graph that silently does +nothing. **One exception:** a `null_resolutions` entry flagged `unverifiable: +true` (or an `unverifiable_bindings` list) is a Composio-upstream binding the +sandbox genuinely can't check — confirm it with `get_tool_contract` rather +than re-wiring, and don't loop on it. + +### Say what you inferred + +In the proposal's summary (or your closing reply if you asked a question +instead), name every INFERABLE choice in half a sentence — "sending as a DM +to you", "using your only connected Gmail account", "running every morning +at 8am since none was specified". This is what makes bucket 2 safe to skip +asking about: the guess stays visible and one message away from being +corrected, never silently locked in. + +Always end a building turn with either a proposal (or revision), or — only +for bucket 3 — a single clarifying question. Never both, never neither. diff --git a/src/openhuman/flows/catalogue.rs b/src/openhuman/flows/catalogue.rs new file mode 100644 index 0000000000..30f39ed26b --- /dev/null +++ b/src/openhuman/flows/catalogue.rs @@ -0,0 +1,147 @@ +//! Saved Flows automations, as entries in the skill catalogue. +//! +//! A user asking "what can this thing already do for me" does not distinguish a +//! SKILL.md bundle from a saved tinyflows graph, and until now the catalogue +//! did. The `## Installed Skills` section listed only bundles and carried ~200 +//! bytes of caveat teaching the model that the list it was reading deliberately +//! omitted half the answer — *"it only knows about entries in this list, not +//! Flows automations — do not call it with a Flows `workflow_id`, it will +//! error"* — plus a pointer to a different tool for the omitted half. Prose +//! that exists to explain a gap is usually cheaper to spend on closing it. +//! +//! So a flow becomes a [`Workflow`] with [`WorkflowScope::Flow`], and the two +//! consumers that answer "what is installed" — the orchestrator's catalogue +//! section and `skill_search` — see one list. +//! +//! # What a Flow entry is not +//! +//! It is a **listing**, not a bundle. Every other scope is a directory the +//! skill scanner walked; this is a row in `flows.db`. There is no `SKILL.md`, +//! so `location` is `None` and `resources` is empty, and the tools that read +//! those (`describe_workflow`, `read_workflow_resource`) must say so by name +//! rather than failing on a missing file. That is the whole reason +//! `WorkflowScope::Flow` is a distinct variant instead of these being smuggled +//! in as `User` skills: the difference is real, and a consumer that needs to +//! know can ask. +//! +//! # Descriptions: the graph's shape +//! +//! Saved flows carry the name and graph metadata supplied by the shared catalog +//! crate, but no separate catalogue description. The summary below therefore +//! describes only the graph's shape, without inventing a purpose from node +//! internals. + +use crate::openhuman::config::Config; +use crate::openhuman::skills::{Workflow, WorkflowScope}; + +/// How many flows the catalogue will surface. +/// +/// Flows are cheap to create and a heavy user can accumulate many, while this +/// list is rendered into a system prompt that is frozen for the whole session. +/// The cap is a ceiling on a per-turn cost that would otherwise grow silently +/// with the contents of a database; every flow past it stays runnable by name, +/// only its catalogue line is gone. Mirrors `MAX_LISTED_SKILLS` in the +/// orchestrator prompt, which caps the same section from the other side. +pub const MAX_CATALOGUE_FLOWS: usize = 20; + +/// Every saved flow, as catalogue entries. +/// +/// Returns an empty vec on any store error rather than propagating: this feeds +/// a prompt section and a search index, and a transient `flows.db` problem +/// should degrade the catalogue, never fail the turn. The error is logged. +pub fn flow_entries(config: &Config) -> Vec { + let (flows, skipped) = match super::store::list_flows(config) { + Ok(pair) => pair, + Err(error) => { + tracing::warn!(%error, "[flows][catalogue] could not list flows; catalogue omits them"); + return Vec::new(); + } + }; + if skipped > 0 { + // `list_flows` documents that a non-zero `skipped` must be surfaced + // loudly rather than treated as a reason to fail. + tracing::warn!( + skipped, + "[flows][catalogue] some flow rows could not be decoded and are absent from the catalogue" + ); + } + + let total = flows.len(); + let mut entries: Vec = flows + .into_iter() + // Disabled flows are deliberately listed. A user who switched one off + // still owns it, and a catalogue that hid it would make the model + // answer "you have no such automation" to someone looking at it in the + // UI. The entry says it is paused; the model can offer to enable it. + .take(MAX_CATALOGUE_FLOWS) + .map(entry_for) + .collect(); + if total > MAX_CATALOGUE_FLOWS { + tracing::debug!( + total, + listed = MAX_CATALOGUE_FLOWS, + "[flows][catalogue] flow list truncated for the prompt catalogue" + ); + } + entries.sort_by(|a, b| a.name.cmp(&b.name)); + entries +} + +fn entry_for(flow: super::types::Flow) -> Workflow { + Workflow { + name: flow.name.clone(), + // The flow id, because that is what `run_workflow` / `get_flow` take. + // A slug of the name would be a second identifier that resolves + // nowhere. + dir_name: flow.id.clone(), + description: describe(&flow), + scope: WorkflowScope::Flow, + // No bundle on disk: no manifest to read, no resources to page + // through. Left explicitly empty so a consumer that reads them gets an + // honest absence rather than a path that does not exist. + location: None, + ..Default::default() + } +} + +/// A structural summary of the flow graph. +fn describe(flow: &super::types::Flow) -> String { + describe_shape(flow) +} + +/// A one-line summary of what the graph *is*, for a flow with no description. +/// +/// Deliberately structural — trigger, size, paused-ness — because that is all +/// the record carries. See the module docs: inventing a purpose from node +/// internals would read as authoritative and frequently be wrong. +fn describe_shape(flow: &super::types::Flow) -> String { + // Read out of the trigger node's free-form config, which is where the + // engine keeps it — the same way `tinyflows`' own `trigger_kind` does. + // A graph with zero or several triggers has no single answer, and + // validation reports that separately, so this stays quiet. + let trigger = flow + .graph + .trigger() + .and_then(|node| node.config.get("trigger_kind")) + .and_then(|value| value.as_str()) + .unwrap_or("manual") + .to_string(); + let steps = flow + .graph + .nodes + .len() + // The trigger is not a step the user thinks about. + .saturating_sub(1); + let mut out = format!( + "Saved Flows automation ({trigger} trigger, {steps} step{}).", + if steps == 1 { "" } else { "s" } + ); + if !flow.enabled { + out.push_str(" Currently disabled."); + } + out +} + +#[cfg(test)] +#[path = "catalogue_tests.rs"] +mod tests; diff --git a/src/openhuman/flows/catalogue_tests.rs b/src/openhuman/flows/catalogue_tests.rs new file mode 100644 index 0000000000..956da1bd95 --- /dev/null +++ b/src/openhuman/flows/catalogue_tests.rs @@ -0,0 +1,274 @@ +//! Tests for the flow → catalogue-entry mapping. + +use super::*; +use crate::openhuman::flows::types::Flow; +use tinyflows::model::{Node, NodeKind, WorkflowGraph}; + +fn node(id: &str, kind: NodeKind, config: serde_json::Value) -> Node { + Node { + id: id.into(), + kind, + name: id.to_string(), + config, + type_version: 1, + ports: Vec::new(), + position: None, + } +} + +fn flow(name: &str, enabled: bool, nodes: Vec) -> Flow { + Flow { + id: "flow-abc-123".to_string(), + name: name.to_string(), + enabled, + graph: WorkflowGraph { + nodes, + ..Default::default() + }, + created_at: String::new(), + updated_at: String::new(), + last_run_at: None, + last_status: None, + require_approval: false, + description: String::new(), + } +} + +fn flow_described(name: &str, description: &str) -> Flow { + Flow { + description: description.to_string(), + ..flow(name, true, vec![]) + } +} + +#[test] +fn the_entry_is_keyed_by_the_flow_id_not_a_slug_of_its_name() { + // `run_workflow` and `get_flow` take the id. A slug of the display name + // would be a second identifier that resolves nowhere, and the model would + // have no way to tell which one it was holding. + let entry = entry_for(flow("Morning Digest", true, vec![])); + assert_eq!(entry.dir_name, "flow-abc-123"); + assert_eq!(entry.name, "Morning Digest"); +} + +#[test] +fn the_entry_is_scoped_flow_and_carries_no_on_disk_bundle() { + // The distinction that makes `WorkflowScope::Flow` worth having: consumers + // that read `location` or `resources` must get an honest absence rather + // than a path that does not exist. + let entry = entry_for(flow("Anything", true, vec![])); + assert_eq!(entry.scope, WorkflowScope::Flow); + assert!(entry.location.is_none()); + assert!(entry.resources.is_empty()); +} + +#[test] +fn the_description_names_the_trigger_and_step_count() { + let f = flow( + "Digest", + true, + vec![ + node( + "t", + NodeKind::Trigger, + serde_json::json!({ "trigger_kind": "schedule" }), + ), + node("a", NodeKind::Agent, serde_json::json!({})), + node("b", NodeKind::Agent, serde_json::json!({})), + ], + ); + let entry = entry_for(f); + assert!( + entry.description.contains("schedule"), + "{}", + entry.description + ); + // Two steps: the trigger is not a step a user thinks about. + assert!( + entry.description.contains("2 steps"), + "{}", + entry.description + ); +} + +#[test] +fn a_single_step_is_not_pluralised() { + let f = flow( + "One", + true, + vec![ + node( + "t", + NodeKind::Trigger, + serde_json::json!({ "trigger_kind": "manual" }), + ), + node("a", NodeKind::Agent, serde_json::json!({})), + ], + ); + assert!(entry_for(f).description.contains("1 step)")); +} + +#[test] +fn a_graph_with_no_trigger_reads_as_manual_rather_than_blank() { + // `graph.trigger()` also returns `None` when there are *several* triggers. + // Either way the catalogue must say something; validation reports the real + // problem separately, so this stays quiet rather than duplicating it. + let entry = entry_for(flow("Headless", true, vec![])); + assert!( + entry.description.contains("manual"), + "{}", + entry.description + ); +} + +#[test] +fn a_disabled_flow_is_still_listed_and_says_it_is_paused() { + // Hiding it would make the model answer "you have no such automation" to + // someone looking straight at it in the UI. + let entry = entry_for(flow("Paused", false, vec![])); + assert!(entry.description.contains("Currently disabled")); +} + +#[test] +fn an_enabled_flow_does_not_claim_to_be_disabled() { + assert!(!entry_for(flow("Live", true, vec![])) + .description + .contains("disabled")); +} + +#[test] +fn an_authored_description_is_used_verbatim() { + // The whole point of the field: when someone says what the automation is + // for, the catalogue says that and not the graph's shape. + let entry = entry_for(flow_described( + "Invoices", + "Files incoming supplier invoices into the accounting folder.", + )); + assert_eq!( + entry.description, + "Files incoming supplier invoices into the accounting folder." + ); + assert!(!entry.description.contains("Saved Flows automation")); +} + +#[test] +fn a_flow_with_no_description_falls_back_to_the_graphs_shape() { + // Not a rare path: every flow saved before the field existed has none, the + // canvas does not force one, and a promoted draft carries none. + let entry = entry_for(flow("Send invoices to accounting", true, vec![])); + assert!( + entry.description.starts_with("Saved Flows automation"), + "fallback must describe the shape: {}", + entry.description + ); + // And it must not guess a purpose out of the name. + assert!(!entry.description.contains("invoice")); +} + +#[test] +fn a_whitespace_only_description_falls_back_rather_than_rendering_blank() { + // A blank catalogue line reads as a broken entry. `" "` reaches here + // from a canvas field someone tabbed through. + let entry = entry_for(flow_described("Spaces", " ")); + assert!(entry.description.starts_with("Saved Flows automation")); +} + +#[test] +fn the_paused_note_survives_an_authored_description() { + // Whether a flow currently runs is a fact about the record, not about its + // purpose, so an author's line must not suppress it. + let mut f = flow_described("Paused", "Posts the weekly digest to Slack."); + f.enabled = false; + let entry = entry_for(f); + assert!(entry.description.contains("Posts the weekly digest")); + assert!(entry.description.contains("Currently disabled")); +} + +// ── Against a real store ────────────────────────────────────────────────── +// +// The mapping tests above never touch `flows.db`. These do, because the +// interesting failure is not the mapping — it is `flow_entries` reading the +// wrong database, or silently swallowing a real one. A unit test over +// `entry_for` would pass in both cases. + +use tempfile::TempDir; + +fn store_config(tmp: &TempDir) -> crate::openhuman::config::Config { + let config = crate::openhuman::config::Config { + workspace_dir: tmp.path().join("workspace"), + action_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..Default::default() + }; + std::fs::create_dir_all(&config.workspace_dir).unwrap(); + config +} + +#[test] +fn an_empty_store_contributes_nothing_to_the_catalogue() { + // Also the common case: most workspaces have no flows, and the catalogue + // must not grow a `[flow]` header or an empty group for them. + let tmp = TempDir::new().unwrap(); + assert!(flow_entries(&store_config(&tmp)).is_empty()); +} + +#[test] +fn a_saved_flow_reaches_the_catalogue_with_its_real_id() { + let tmp = TempDir::new().unwrap(); + let config = store_config(&tmp); + + let graph = WorkflowGraph { + nodes: vec![ + node( + "t", + NodeKind::Trigger, + serde_json::json!({ "trigger_kind": "manual" }), + ), + node("a", NodeKind::Agent, serde_json::json!({})), + ], + ..Default::default() + }; + let saved = super::super::store::create_flow( + &config, + "Weekly Report".to_string(), + String::new(), + graph, + false, + true, + ) + .expect("flow saves"); + + let entries = flow_entries(&config); + assert_eq!(entries.len(), 1, "the saved flow must appear: {entries:?}"); + let entry = &entries[0]; + assert_eq!(entry.name, "Weekly Report"); + // The store's generated id, not one this test made up — that is what + // `run_workflow` will be handed. + assert_eq!(entry.dir_name, saved.id); + assert_eq!(entry.scope, WorkflowScope::Flow); + assert!(entry.description.contains("manual trigger")); + assert!(entry.description.contains("1 step)")); +} + +#[test] +fn entries_are_sorted_by_name_so_the_prompt_prefix_is_stable() { + // The catalogue is rendered into a system prompt that is frozen for a whole + // session and cached by prefix. Insertion-order listing would reshuffle the + // prefix whenever a flow was created, invalidating the cache for reasons + // unrelated to the conversation. + let tmp = TempDir::new().unwrap(); + let config = store_config(&tmp); + for name in ["Zebra", "Alpha", "Mango"] { + super::super::store::create_flow( + &config, + name.to_string(), + String::new(), + WorkflowGraph::default(), + false, + true, + ) + .expect("flow saves"); + } + let names: Vec = flow_entries(&config).into_iter().map(|e| e.name).collect(); + assert_eq!(names, vec!["Alpha", "Mango", "Zebra"]); +} diff --git a/src/openhuman/flows/medulla_bridge_tests.rs b/src/openhuman/flows/medulla_bridge_tests.rs index 0315007f0e..699c26f2eb 100644 --- a/src/openhuman/flows/medulla_bridge_tests.rs +++ b/src/openhuman/flows/medulla_bridge_tests.rs @@ -61,6 +61,7 @@ fn flow(id: &str, name: &str, graph: WorkflowGraph) -> Flow { last_run_at: None, last_status: None, require_approval: false, + description: String::new(), } } @@ -250,6 +251,7 @@ async fn list_and_get_answer_out_of_the_real_store() { let created = ops::flows_create( &config, "Deploy".to_string(), + String::new(), serde_json::to_value(graph_with_step("Ship it")).unwrap(), false, ) @@ -295,6 +297,7 @@ async fn runs_answers_with_an_empty_window_for_a_flow_that_never_ran() { let created = ops::flows_create( &config, "Deploy".to_string(), + String::new(), serde_json::to_value(graph_with_step("Ship it")).unwrap(), false, ) @@ -341,6 +344,7 @@ async fn an_update_cannot_lower_the_approval_requirement() { let created = ops::flows_create( &config, "Deploy".to_string(), + String::new(), serde_json::to_value(graph_with_step("Ship it")).unwrap(), true, ) @@ -392,6 +396,7 @@ async fn a_remote_automatic_revision_requires_explicit_rearming() { let created = ops::flows_create( &config, "Scheduled".to_string(), + String::new(), schedule_graph("0 9 * * *"), true, ) @@ -436,6 +441,7 @@ async fn an_update_refuses_to_overwrite_a_concurrent_edit() { let created = ops::flows_create( &config, "Deploy".to_string(), + String::new(), serde_json::to_value(graph_with_step("Ship it")).unwrap(), true, ) @@ -449,6 +455,7 @@ async fn an_update_refuses_to_overwrite_a_concurrent_edit() { Some("User edit".to_string()), None, None, + None, Some(created.updated_at.clone()), ) .await diff --git a/src/openhuman/flows/mod.rs b/src/openhuman/flows/mod.rs index 4a9cc9f41e..a937282535 100644 --- a/src/openhuman/flows/mod.rs +++ b/src/openhuman/flows/mod.rs @@ -31,6 +31,7 @@ pub mod agents; pub mod builder_tools; pub mod bus; +pub mod catalogue; pub mod discovery_tools; mod draft_store; #[cfg(test)] @@ -41,6 +42,10 @@ pub mod memory_tools; pub mod node_contracts; pub mod ops; mod schemas; +/// Skills this domain ships inside the binary. Needs BOTH gates: the pages +/// teach flows authoring, and `BundledSkill` is part of the skills subsystem. +#[cfg(feature = "skills")] +pub mod skills; mod store; /// The tinyflows engine seam (formerly `openhuman::tinyflows`). pub mod tinyflows; @@ -69,8 +74,8 @@ pub use schemas::{ // lives in the sibling `tinyflows` domain and persists each finished step onto // the `flow_runs` row through this function as the run executes. pub use node_contracts::{ - all_node_kind_contracts, node_kind_contract, render_node_kinds_line, ConfigField, - NodeKindContract, PortSpec, NODE_KINDS, + all_node_kind_contracts, node_kind_contract, render_node_kinds_line, + render_node_kinds_required, ConfigField, NodeKindContract, PortSpec, NODE_KINDS, }; pub use store::{kv_get, kv_set, upsert_flow_run_step}; pub use tinyflows_catalog::{ diff --git a/src/openhuman/flows/node_contracts.rs b/src/openhuman/flows/node_contracts.rs index c95df61157..02568086e6 100644 --- a/src/openhuman/flows/node_contracts.rs +++ b/src/openhuman/flows/node_contracts.rs @@ -208,6 +208,27 @@ pub fn render_node_kinds_line() -> String { .join(" | ") } +/// Renders the node kinds and only their required configuration fields. +pub fn render_node_kinds_required() -> String { + all_node_kind_contracts() + .iter() + .map(|c| { + let required: Vec<&str> = c + .config_fields + .iter() + .filter(|f| f.required) + .map(|f| f.name.as_str()) + .collect(); + if required.is_empty() { + c.kind.clone() + } else { + format!("{}(config.{})", c.kind, required.join(", config.")) + } + }) + .collect::>() + .join(", ") +} + #[cfg(test)] #[path = "node_contracts_tests.rs"] mod tests; diff --git a/src/openhuman/flows/skills/flow-authoring/WORKFLOW.md b/src/openhuman/flows/skills/flow-authoring/WORKFLOW.md new file mode 100644 index 0000000000..39d827f31d --- /dev/null +++ b/src/openhuman/flows/skills/flow-authoring/WORKFLOW.md @@ -0,0 +1,53 @@ +--- +name: flow-authoring +description: The tinyflows authoring reference — expression and jq syntax, node configuration for memory/dedup/trigger nodes, per-node error handling, and how to read a dry run honestly. Read a page before configuring the thing it covers. +metadata: + version: "1.0.0" + author: OpenHuman + tags: + - flows + - workflows + - authoring + - reference +allowed-tools: + - read_workflow_resource + - get_node_kind_contract + - list_node_kinds +--- + +# Authoring a tinyflows workflow + +This is a **reference manual, not a procedure**. It holds the exact rules that +are too long to keep in a system prompt and too precise to reconstruct from +memory: an expression convention you half-remember produces a graph that +validates and then does the wrong thing at run time. + +Read the one page that covers what you are about to configure. + +| page | read it before you | +| --- | --- | +| `references/expressions.md` | write any `=` expression or jq filter, or attach a produced file to an outbound action | +| `references/node-config.md` | configure a `memory`, `dedup` or `trigger` node, or set per-node error handling | +| `references/dry-run.md` | report what a dry run did and did not prove | + +Fetch one with: + +``` +read_workflow_resource { skill_id: "flow-authoring", relative_path: "references/expressions.md" } +``` + +## What is deliberately not here + +**Per-kind configuration.** `get_node_kind_contract { kind }` returns a node +kind's config fields, ports, a worked example and its gotchas, and it is +generated from the same catalog the validator enforces — so it cannot go stale +the way this text can. Where the two disagree, the contract tool is right. +These pages cover the rules that span kinds, which is why they have nowhere +generated to live. + +**The rules you must not break.** Propose rather than persist, ask before a +real run, ground every slug, prefer the minimal viable graph — those stay in +the system prompt, because a rule that only binds once someone chooses to read +it is not a rule. Graph sizing was moved here during an earlier pass and moved +back for exactly that reason: it shapes every graph, including the ones built +without opening a manual. diff --git a/src/openhuman/flows/skills/flow-authoring/references/dry-run.md b/src/openhuman/flows/skills/flow-authoring/references/dry-run.md new file mode 100644 index 0000000000..5c10f2ffc1 --- /dev/null +++ b/src/openhuman/flows/skills/flow-authoring/references/dry-run.md @@ -0,0 +1,8 @@ +# Reading a dry run + +A clean dry run verifies the graph and bindings that the sandbox can inspect; +it does not execute external side effects or prove that a provider will accept +every live value. Treat `null_resolutions`, rejected contracts, and agent +prompt nulls as unfinished work. An `unverifiable` binding is an external +provider limitation: confirm its field with `get_tool_contract` and report the +limitation honestly. diff --git a/src/openhuman/flows/skills/flow-authoring/references/expressions.md b/src/openhuman/flows/skills/flow-authoring/references/expressions.md new file mode 100644 index 0000000000..9ceeeca839 --- /dev/null +++ b/src/openhuman/flows/skills/flow-authoring/references/expressions.md @@ -0,0 +1,10 @@ +# Expressions and filters + +Use `=` when a value is taken from the current item or an earlier node. Use +`=item` for the current item and `=nodes..item.json.` for a named +upstream result. Keep natural-language prompts as plain text; do not prefix +them with `=`. Verify bindings with `dry_run_workflow` before proposing a flow. + +For list transformations, use the jq filter syntax accepted by the code node +and keep the result an array of items. Preserve the `data` wrapper on +Composio results when addressing their fields. diff --git a/src/openhuman/flows/skills/flow-authoring/references/node-config.md b/src/openhuman/flows/skills/flow-authoring/references/node-config.md new file mode 100644 index 0000000000..04894c6b34 --- /dev/null +++ b/src/openhuman/flows/skills/flow-authoring/references/node-config.md @@ -0,0 +1,9 @@ +# Node configuration + +Read `get_node_kind_contract` for the authoritative fields and ports of each +node kind. Memory nodes use an explicit scope; dedup nodes need a stable key; +trigger nodes need a supported trigger kind and its required configuration. + +Set error handling deliberately. Required connection and credential values +must be wired from a trusted source or an existing connection, never guessed. +Validate the complete graph after configuration. diff --git a/src/openhuman/flows/skills/mod.rs b/src/openhuman/flows/skills/mod.rs new file mode 100644 index 0000000000..d65cf5e036 --- /dev/null +++ b/src/openhuman/flows/skills/mod.rs @@ -0,0 +1,67 @@ +//! Skills the flows domain ships inside the binary. +//! +//! `flow-authoring` is the Workflow Builder's reference manual. It used to be +//! ~25 KB of the agent's system prompt — paid on every turn of every authoring +//! session, including the turns that only wire two nodes together. As a bundled +//! skill the same text is one `read_workflow_resource` call away and costs +//! nothing until a page is actually needed. +//! +//! The split follows one rule, and it is the reason the whole prompt did not +//! move: **a rule that binds goes in the prompt; a rule you look up goes +//! here.** "Propose, never persist" cannot live in a manual, because a manual +//! only binds a model that chose to open it. An expression's jq syntax is the +//! opposite — nothing goes wrong by not knowing it until you need it, and a +//! lot goes wrong by half-remembering it. +//! +//! The line is not obvious from the outside, and getting it wrong is caught by +//! tests rather than by review: "prefer the minimal viable graph" was moved +//! here on the first pass and moved back, because +//! `standing_prompt_keeps_minimal_graph_warning_alongside_specialist_guidance` +//! pins it in the prompt — correctly. It constrains an instinct the model has +//! before it would think to consult anything. +//! +//! # Where this belongs eventually +//! +//! Upstream, in tinyflows. The pages name no OpenHuman type and no host +//! concept beyond the tool slugs, so moving them is a directory move plus a +//! changed `include_str!` path. The pinned `vendor/tinyflows` submodule has no +//! crate to hold them yet — there is no `tinyflows-copilot` in it — so they sit +//! with the flows domain here in the meantime. Keeping them free of host +//! coupling is what keeps that move cheap; do not reach into `crate::` from a +//! page. + +use crate::openhuman::skills::bundled::{BundledFile, BundledSkill}; + +/// The `flow-authoring` bundle, embedded from the sibling directory. +/// +/// Listed file by file rather than swept from the directory: a build-time +/// directory walk would silently ship whatever happened to be sitting there, +/// and `include_str!` needs literal paths anyway. The `bundled_skill_matches_ +/// the_directory_on_disk` test below fails when a page is added to the +/// directory and not to this list, which is the mistake this shape actually +/// invites. +pub const FLOW_AUTHORING: BundledSkill = BundledSkill { + dir_name: "flow-authoring", + files: &[ + BundledFile { + path: "WORKFLOW.md", + contents: include_str!("flow-authoring/WORKFLOW.md"), + }, + BundledFile { + path: "references/expressions.md", + contents: include_str!("flow-authoring/references/expressions.md"), + }, + BundledFile { + path: "references/node-config.md", + contents: include_str!("flow-authoring/references/node-config.md"), + }, + BundledFile { + path: "references/dry-run.md", + contents: include_str!("flow-authoring/references/dry-run.md"), + }, + ], +}; + +#[cfg(test)] +#[path = "skills_tests.rs"] +mod tests; diff --git a/src/openhuman/flows/skills/skills_tests.rs b/src/openhuman/flows/skills/skills_tests.rs new file mode 100644 index 0000000000..cf90142966 --- /dev/null +++ b/src/openhuman/flows/skills/skills_tests.rs @@ -0,0 +1,135 @@ +use super::*; + +fn skill_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src/openhuman/flows/skills/flow-authoring") +} + +#[test] +fn bundled_skill_matches_the_directory_on_disk() { + // A page added to the directory but not to `FLOW_AUTHORING` is not a + // compile error and not a test failure anywhere else — it simply never + // ships, and the prompt's pointer table sends the model to a file that + // does not exist. This is that check. + let root = skill_dir(); + let mut on_disk = Vec::new(); + for entry in walkdir(&root) { + let rel = entry + .strip_prefix(&root) + .expect("under root") + .to_string_lossy() + .replace('\\', "/"); + on_disk.push(rel); + } + on_disk.sort(); + + let mut listed: Vec = FLOW_AUTHORING + .files + .iter() + .map(|f| f.path.to_string()) + .collect(); + listed.sort(); + + assert_eq!( + listed, on_disk, + "FLOW_AUTHORING's file list and the on-disk bundle have diverged" + ); +} + +fn walkdir(root: &std::path::Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).expect("read_dir").flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + out.push(path); + } + } + } + out +} + +#[test] +fn every_page_the_manifest_advertises_exists() { + // The WORKFLOW.md table is what the model reads to choose a page. A + // row naming a file that does not ship is a dead end the model cannot + // diagnose — it just gets an error and gives up on the manual. + let manifest = FLOW_AUTHORING + .files + .iter() + .find(|f| f.path == "WORKFLOW.md") + .expect("manifest") + .contents; + for file in FLOW_AUTHORING.files { + if file.path == "WORKFLOW.md" { + continue; + } + assert!( + manifest.contains(file.path), + "`{}` ships but the manifest's table never names it", + file.path + ); + } + for line in manifest.lines() { + for token in line.split('`') { + if token.starts_with("references/") { + assert!( + FLOW_AUTHORING.files.iter().any(|f| f.path == token), + "the manifest points at `{token}`, which does not ship" + ); + } + } + } +} + +#[test] +fn the_frontmatter_description_does_not_advertise_a_dropped_page() { + // The description is what the model reads in the `## Installed Skills` + // catalogue to decide whether to open the skill at all, and it is prose + // rather than a path — so the `references/` token check below cannot + // see it. It went stale the first time a page moved back into the + // standing prompt: the description still promised graph sizing after + // `graph-shape.md` was deleted. + let manifest = FLOW_AUTHORING + .files + .iter() + .find(|f| f.path == "WORKFLOW.md") + .expect("manifest") + .contents; + let description = manifest + .lines() + .find(|l| l.starts_with("description:")) + .expect("frontmatter description"); + for dropped in ["how large a graph", "graph should be", "graph-shape"] { + assert!( + !description.contains(dropped), + "the description still advertises `{dropped}`, which no longer ships" + ); + } +} + +#[test] +fn the_builder_prompt_points_at_pages_that_ship() { + // Same check from the other side. The prompt carries its own copy of + // the table (the model needs to know the manual exists before it has + // read the manual), so the two can drift independently. + const PROMPT: &str = include_str!("../agents/workflow_builder/prompt.md"); + assert!( + PROMPT.contains("flow-authoring"), + "the builder prompt must name the skill that holds its reference manual" + ); + let mut pointed = 0; + for token in PROMPT.split('`') { + if token.starts_with("references/") { + pointed += 1; + assert!( + FLOW_AUTHORING.files.iter().any(|f| f.path == token), + "the builder prompt points at `{token}`, which does not ship" + ); + } + } + assert!(pointed >= 3, "the prompt's pointer table lost its rows"); +} diff --git a/src/openhuman/flows/store_tests_part_01.rs b/src/openhuman/flows/store_tests_part_01.rs new file mode 100644 index 0000000000..391380a431 --- /dev/null +++ b/src/openhuman/flows/store_tests_part_01.rs @@ -0,0 +1,700 @@ +use super::*; +use crate::openhuman::config::Config; +use tempfile::TempDir; +use tinyflows::model::{Node, NodeKind, WorkflowGraph}; + +fn test_config(tmp: &TempDir) -> Config { + let config = Config { + workspace_dir: tmp.path().join("workspace"), + action_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..Config::default() + }; + std::fs::create_dir_all(&config.workspace_dir).unwrap(); + config +} + +fn trigger_graph() -> WorkflowGraph { + WorkflowGraph { + nodes: vec![Node { + id: "t".to_string(), + kind: NodeKind::Trigger, + type_version: 1, + name: "Trigger".to_string(), + config: serde_json::Value::Null, + ports: Vec::new(), + position: None, + }], + ..Default::default() + } +} + +/// An automatic-trigger (`schedule`) graph — `trigger_is_automatic` returns +/// `true` for this, unlike [`trigger_graph`]'s manual (no `trigger_kind`) +/// trigger. +fn automatic_schedule_graph() -> WorkflowGraph { + WorkflowGraph { + nodes: vec![Node { + id: "t".to_string(), + kind: NodeKind::Trigger, + type_version: 1, + name: "Trigger".to_string(), + config: serde_json::json!({ "trigger_kind": "schedule", "schedule": "0 9 * * *" }), + ports: Vec::new(), + position: None, + }], + ..Default::default() + } +} + +#[test] +fn create_get_list_delete_roundtrip() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + assert_eq!(flow.name, "demo"); + assert!(flow.enabled); + + let fetched = get_flow(&config, &flow.id).unwrap().expect("flow present"); + assert_eq!(fetched.id, flow.id); + assert_eq!(fetched.graph, flow.graph); + + let (listed, skipped) = list_flows(&config).unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, flow.id); + assert_eq!(skipped, 0); + + remove_flow(&config, &flow.id).unwrap(); + assert!(get_flow(&config, &flow.id).unwrap().is_none()); + assert!(list_flows(&config).unwrap().0.is_empty()); +} + +#[test] +fn get_flow_returns_none_for_unknown_id() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + assert!(get_flow(&config, "missing").unwrap().is_none()); +} + +#[test] +fn remove_flow_errors_when_not_found() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let err = remove_flow(&config, "missing").unwrap_err(); + assert!(err.to_string().contains("not found")); +} + +#[test] +fn set_enabled_toggles_and_persists() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + assert!(flow.enabled); + + let disabled = set_enabled(&config, &flow.id, false).unwrap(); + assert!(!disabled.enabled); + + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(!reloaded.enabled); + + let enabled = set_enabled(&config, &flow.id, true).unwrap(); + assert!(enabled.enabled); +} + +#[test] +fn update_flow_graph_bumps_updated_at_and_preserves_created_at() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + let mut new_graph = trigger_graph(); + new_graph.name = "renamed-graph".to_string(); + let updated = update_flow_graph( + &config, + &flow.id, + "renamed".to_string(), + None, + new_graph, + false, + None, + false, + None, + ) + .unwrap(); + + assert_eq!(updated.name, "renamed"); + assert_eq!(updated.created_at, flow.created_at); + assert_eq!(updated.graph.name, "renamed-graph"); +} + +/// `enabled_override: None` must leave the persisted `enabled` column +/// exactly as it was — `update_flow_graph` re-reads the current row and +/// falls back to `current.enabled`, not to whatever the caller might have +/// observed earlier. +#[test] +fn update_flow_graph_with_none_override_preserves_current_enabled_column() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + assert!(flow.enabled, "flow created enabled"); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + None, + trigger_graph(), + false, + None, // enabled_override + false, // force_disarm_if_automatic + None, + ) + .unwrap(); + + assert!( + updated.enabled, + "a None override must preserve the row's current enabled state" + ); + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(reloaded.enabled); +} + +/// `enabled_override: Some(false)` must force-persist `enabled=false` +/// regardless of what the row's `enabled` column currently holds — this is +/// the mechanism `flows_update`'s B29 Rule 1 analogue relies on to disarm a +/// manual→automatic trigger transition in the same guarded write. +#[test] +fn update_flow_graph_with_some_false_override_forces_disabled() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + assert!(flow.enabled, "flow created enabled"); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + None, + trigger_graph(), + false, + Some(false), // enabled_override + false, // force_disarm_if_automatic + None, + ) + .unwrap(); + + assert!( + !updated.enabled, + "a Some(false) override must force enabled=false even though the row was enabled" + ); + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(!reloaded.enabled); +} + +/// Regression for the silent live-arming race Codex flagged on this PR: +/// `flows_update` (ops.rs) makes its manual→automatic disarm decision from +/// an *outer* `existing` read taken before `update_flow_graph`'s own guarded +/// UPDATE re-reads the row. If a concurrent `flows_set_enabled(id, true)` +/// landed in that gap — which bumps `updated_at`, so it would NOT trip the +/// optimistic-concurrency conflict — the outer read would be stale while the +/// row is actually enabled by write time. This proves the mechanism the fix +/// relies on to close that race: an `enabled_override` of `Some(false)` +/// (what `flows_update` now passes unconditionally on a manual→automatic +/// transition, never gated on the stale outer read) always wins over +/// whatever the row's `enabled` column was concurrently flipped to, +/// simulated here by flipping it with `set_enabled` between the two calls. +#[test] +fn update_flow_graph_override_wins_over_concurrently_enabled_row() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + false, + ) + .unwrap(); + assert!(!flow.enabled, "flow created disabled"); + + // Simulates a concurrent `flows_set_enabled(id, true)` racing in after + // `flows_update`'s outer `existing` read observed `enabled: false`, but + // before its guarded `update_flow_graph` write below. + let raced = set_enabled(&config, &flow.id, true).unwrap(); + assert!(raced.enabled); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + None, + trigger_graph(), + false, + Some(false), // the unconditional disarm override + false, // force_disarm_if_automatic + None, + ) + .unwrap(); + + assert!( + !updated.enabled, + "the disarm override must win over a concurrently-enabled row, not the reverse" + ); + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(!reloaded.enabled); +} + +/// R-m2 regression: the manual→automatic disarm decision must be computed +/// against the row `update_flow_graph` JUST re-read (`current`), never a +/// caller-supplied belief about the flow's prior state. Before the fix, +/// `ops::flows_update` computed this transition from an OUTER `existing` +/// read taken before calling into the store — a concurrent write between +/// that read and this call could make the transition invisible to the +/// caller, letting an automatic-trigger graph persist `enabled: true`. +/// +/// Proven here without needing to fake a race: the disarm must fire from +/// `current.graph` (MANUAL) vs the new `graph` (automatic) alone, and must +/// WIN over an `enabled_override` that explicitly asks to stay enabled — +/// exactly the shape of override a stale caller-side decision could +/// otherwise have smuggled through. +#[test] +fn update_flow_graph_disarms_transition_from_the_fresh_row_even_when_override_asks_to_stay_enabled() +{ + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + assert!(flow.enabled, "flow created enabled"); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + None, + automatic_schedule_graph(), + false, + Some(true), // caller explicitly asks to stay enabled + false, // force_disarm_if_automatic (the remote-authoring flag) OFF — + // proving the unconditional Rule 1 transition-disarm fires on its own + None, + ) + .unwrap(); + + assert!( + !updated.enabled, + "a manual->automatic transition must disarm even when enabled_override asks to stay \ + enabled — the disarm always wins (R-m2)" + ); + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(!reloaded.enabled); +} + +/// Sibling of the above: when there is NO transition (the row was already +/// automatic before this call, matching what's actually in the DB right +/// now), an ordinary `enabled_override` is honoured normally — the fix must +/// not over-disarm every automatic-trigger update, only genuine +/// manual/none → automatic transitions (unless `force_disarm_if_automatic` +/// is also set). +#[test] +fn update_flow_graph_does_not_disarm_an_automatic_to_automatic_update() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + automatic_schedule_graph(), + false, + false, + ) + .unwrap(); + assert!(!flow.enabled, "born disabled — armed explicitly next"); + let armed = set_enabled(&config, &flow.id, true).unwrap(); + assert!(armed.enabled); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + None, + automatic_schedule_graph(), + false, + None, // no explicit override — preserve current.enabled + false, // force_disarm_if_automatic OFF + None, + ) + .unwrap(); + + assert!( + updated.enabled, + "an automatic->automatic update (no transition) must not be auto-disarmed" + ); +} + +#[test] +fn record_run_sets_last_run_fields() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + assert!(flow.last_run_at.is_none()); + + record_run(&config, &flow.id, "completed").unwrap(); + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(reloaded.last_run_at.is_some()); + assert_eq!(reloaded.last_status.as_deref(), Some("completed")); +} + +#[test] +fn stored_graph_older_than_current_schema_is_migrated_on_read() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + // Insert a raw, versionless graph row directly (bypassing create_flow's + // typed path) to simulate a definition persisted by an older crate build. + let legacy_graph_json = serde_json::json!({ + "name": "legacy", + "nodes": [{ "id": "t", "kind": "trigger", "name": "Trigger" }], + "edges": [] + }) + .to_string(); + + with_connection(&config, |conn| { + conn.execute( + "INSERT INTO flow_definitions + (id, name, graph_json, enabled, created_at, updated_at, last_run_at, last_status) + VALUES ('legacy-1', 'legacy', ?1, 1, '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z', NULL, NULL)", + rusqlite::params![legacy_graph_json], + )?; + Ok(()) + }) + .unwrap(); + + let loaded = get_flow(&config, "legacy-1").unwrap().expect("row present"); + assert_eq!( + loaded.graph.schema_version, + tinyflows::model::CURRENT_SCHEMA_VERSION + ); + assert_eq!(loaded.graph.nodes.len(), 1); +} + +#[test] +fn kv_get_set_round_trips_and_is_namespace_scoped() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + assert!(kv_get(&config, "ns1", "k").unwrap().is_none()); + + kv_set(&config, "ns1", "k", &serde_json::json!({"v": 1})).unwrap(); + assert_eq!( + kv_get(&config, "ns1", "k").unwrap(), + Some(serde_json::json!({"v": 1})) + ); + + // A different namespace does not see ns1's value. + assert!(kv_get(&config, "ns2", "k").unwrap().is_none()); + + // Overwrite. + kv_set(&config, "ns1", "k", &serde_json::json!(2)).unwrap(); + assert_eq!( + kv_get(&config, "ns1", "k").unwrap(), + Some(serde_json::json!(2)) + ); +} + +// ── require_approval ───────────────────────────────────────────────────── + +#[test] +fn create_flow_persists_require_approval() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + true, + true, + ) + .unwrap(); + assert!(flow.require_approval); + + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(reloaded.require_approval); +} + +#[test] +fn update_flow_graph_can_change_require_approval() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + assert!(!flow.require_approval); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + None, + trigger_graph(), + true, + None, + false, + None, + ) + .unwrap(); + assert!(updated.require_approval); + + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(reloaded.require_approval); +} + +#[test] +fn legacy_flow_definitions_row_without_require_approval_column_defaults_false() { + // A row inserted before the `require_approval` column existed. Schema + // init (including the `add_column_if_missing` ALTER) runs once per + // process per database file (R-m8) — since this test opens a fresh + // per-`TempDir` database, that one-time init still runs here, simulating + // a workspace opened once on an older build. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let legacy_graph_json = serde_json::to_string(&trigger_graph()).unwrap(); + with_connection(&config, |conn| { + conn.execute( + "INSERT INTO flow_definitions + (id, name, graph_json, enabled, created_at, updated_at, last_run_at, last_status) + VALUES ('legacy-2', 'legacy', ?1, 1, '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z', NULL, NULL)", + rusqlite::params![legacy_graph_json], + )?; + Ok(()) + }) + .unwrap(); + + let loaded = get_flow(&config, "legacy-2").unwrap().expect("row present"); + assert!(!loaded.require_approval); +} + +// ── list_enabled_flows ──────────────────────────────────────────────────── + +#[test] +fn list_enabled_flows_excludes_disabled() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let enabled_flow = create_flow( + &config, + "enabled".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + let disabled_flow = create_flow( + &config, + "disabled".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + set_enabled(&config, &disabled_flow.id, false).unwrap(); + + let (enabled, skipped) = list_enabled_flows(&config).unwrap(); + assert_eq!(enabled.len(), 1); + assert_eq!(enabled[0].id, enabled_flow.id); + assert_eq!(skipped, 0); +} + +// ── flow_runs CRUD ──────────────────────────────────────────────────────── + +#[test] +fn flow_run_insert_finish_get_round_trip() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + let thread_id = format!("flow:{}:run-1", flow.id); + insert_flow_run( + &config, + &thread_id, + &flow.id, + &thread_id, + "2026-01-01T00:00:00Z", + ) + .unwrap(); + + let running = get_flow_run(&config, &thread_id) + .unwrap() + .expect("row present"); + assert_eq!(running.status, "running"); + assert!(running.finished_at.is_none()); + assert!(running.steps.is_empty()); + + let steps = vec![FlowRunStep { + node_id: "t".to_string(), + output: serde_json::json!([{"json": {"x": 1}}]), + port: None, + ..Default::default() + }]; + finish_flow_run( + &config, + &thread_id, + "completed", + "2026-01-01T00:00:01Z", + &steps, + &[], + None, + None, + ) + .unwrap(); + + let finished = get_flow_run(&config, &thread_id) + .unwrap() + .expect("row present"); + assert_eq!(finished.status, "completed"); + assert_eq!( + finished.finished_at.as_deref(), + Some("2026-01-01T00:00:01Z") + ); + assert_eq!(finished.steps.len(), 1); + assert_eq!(finished.steps[0].node_id, "t"); + assert!(finished.pending_approvals.is_empty()); + assert!(finished.error.is_none()); +} + +#[test] +fn finish_flow_run_records_error_on_failure() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + let thread_id = format!("flow:{}:run-2", flow.id); + insert_flow_run( + &config, + &thread_id, + &flow.id, + &thread_id, + "2026-01-01T00:00:00Z", + ) + .unwrap(); + + finish_flow_run( + &config, + &thread_id, + "failed", + "2026-01-01T00:00:01Z", + &[], + &[], + Some("boom"), + None, + ) + .unwrap(); + + let finished = get_flow_run(&config, &thread_id).unwrap().unwrap(); + assert_eq!(finished.status, "failed"); + assert_eq!(finished.error.as_deref(), Some("boom")); +} + +#[test] +fn get_flow_run_returns_none_for_unknown_id() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + assert!(get_flow_run(&config, "missing").unwrap().is_none()); +} + +#[test] +fn list_flow_runs_orders_newest_first_and_is_scoped_to_flow() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow_a = create_flow( + &config, + "a".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + let flow_b = create_flow( diff --git a/src/openhuman/flows/store_tests_part_02.rs b/src/openhuman/flows/store_tests_part_02.rs new file mode 100644 index 0000000000..372a6e0c65 --- /dev/null +++ b/src/openhuman/flows/store_tests_part_02.rs @@ -0,0 +1,700 @@ + &config, + "b".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + insert_flow_run( + &config, + "run-a1", + &flow_a.id, + "run-a1", + "2026-01-01T00:00:00Z", + ) + .unwrap(); + insert_flow_run( + &config, + "run-a2", + &flow_a.id, + "run-a2", + "2026-01-02T00:00:00Z", + ) + .unwrap(); + insert_flow_run( + &config, + "run-b1", + &flow_b.id, + "run-b1", + "2026-01-01T00:00:00Z", + ) + .unwrap(); + + let runs_a = list_flow_runs(&config, &flow_a.id, 10).unwrap(); + assert_eq!(runs_a.len(), 2); + assert_eq!(runs_a[0].id, "run-a2", "newest run must come first"); + assert_eq!(runs_a[1].id, "run-a1"); + + let runs_b = list_flow_runs(&config, &flow_b.id, 10).unwrap(); + assert_eq!(runs_b.len(), 1); + assert_eq!(runs_b[0].id, "run-b1"); +} + +// ── insert_duplicate_flow ───────────────────────────────────────────────── + +#[test] +fn insert_duplicate_flow_makes_a_disabled_copy_with_new_id_and_same_graph() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + // Enabled source with require_approval + a distinctive graph name. + let mut graph = trigger_graph(); + graph.name = "original-graph".to_string(); + let source = create_flow( + &config, + "My Flow".to_string(), + String::new(), + graph, + true, + true, + ) + .unwrap(); + assert!(source.enabled); + record_run(&config, &source.id, "completed").unwrap(); + let source = get_flow(&config, &source.id).unwrap().unwrap(); + assert!(source.last_status.is_some()); + + let copy = insert_duplicate_flow(&config, &source, "My Flow (copy)".to_string()).unwrap(); + + // New id, suffixed name, DISABLED, run history reset. + assert_ne!(copy.id, source.id); + assert_eq!(copy.name, "My Flow (copy)"); + assert!( + !copy.enabled, + "duplicate must be disabled so it never fires" + ); + assert!(copy.last_run_at.is_none()); + assert!(copy.last_status.is_none()); + // Same graph + require_approval carried over. + assert_eq!(copy.graph, source.graph); + assert_eq!(copy.graph.name, "original-graph"); + assert!(copy.require_approval); + + // Persisted and independent — both rows exist. + let reloaded = get_flow(&config, ©.id).unwrap().unwrap(); + assert!(!reloaded.enabled); + assert_eq!(reloaded.graph, source.graph); + assert_eq!(list_flows(&config).unwrap().0.len(), 2); +} + +// ── prune_flow_runs ─────────────────────────────────────────────────────── + +fn seed_run(config: &Config, flow_id: &str, id: &str, day: u32, status: &str) { + let started = format!("2026-01-{day:02}T00:00:00Z"); + insert_flow_run(config, id, flow_id, id, &started).unwrap(); + if status != "running" { + finish_flow_run( + config, + id, + status, + &format!("2026-01-{day:02}T00:00:05Z"), + &[], + &[], + None, + None, + ) + .unwrap(); + } +} + +#[test] +fn prune_flow_runs_keeps_newest_n_terminal_runs() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + // 5 completed runs on ascending days. + for i in 1..=5 { + seed_run(&config, &flow.id, &format!("run-{i}"), i, "completed"); + } + + let deleted = prune_flow_runs(&config, &flow.id, 2).unwrap(); + assert_eq!(deleted, 3, "5 terminal runs, keep 2 => 3 pruned"); + + let remaining = list_flow_runs(&config, &flow.id, 100).unwrap(); + let ids: Vec<_> = remaining.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["run-5", "run-4"], "newest two survive"); +} + +#[test] +fn prune_flow_runs_never_removes_pending_approval_run() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + // An OLD parked pending_approval run (day 1) plus newer completed runs. + seed_run(&config, &flow.id, "parked", 1, "pending_approval"); + for i in 2..=5 { + seed_run(&config, &flow.id, &format!("run-{i}"), i, "completed"); + } + + // keep=1 would normally leave only the newest run; the parked one must + // still survive despite being the oldest and outside the newest-1 window. + let deleted = prune_flow_runs(&config, &flow.id, 1).unwrap(); + let remaining = list_flow_runs(&config, &flow.id, 100).unwrap(); + let ids: std::collections::HashSet<_> = remaining.iter().map(|r| r.id.as_str()).collect(); + assert!( + ids.contains("parked"), + "a pending_approval run must never be pruned out from under a resume" + ); + assert!(ids.contains("run-5"), "newest terminal run kept"); + // Only terminal runs 2..4 were eligible; 5 kept by window => 3 deleted. + assert_eq!(deleted, 3); +} + +#[test] +fn prune_flow_runs_leaves_running_rows_alone() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + seed_run(&config, &flow.id, "live", 1, "running"); + for i in 2..=4 { + seed_run(&config, &flow.id, &format!("run-{i}"), i, "completed"); + } + + prune_flow_runs(&config, &flow.id, 1).unwrap(); + let remaining = list_flow_runs(&config, &flow.id, 100).unwrap(); + let ids: std::collections::HashSet<_> = remaining.iter().map(|r| r.id.as_str()).collect(); + assert!(ids.contains("live"), "a running run is never pruned"); +} + +#[test] +fn insert_flow_run_auto_prunes_beyond_retention_cap() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + // Seed exactly MAX_FLOW_RUNS_PER_FLOW completed runs. + let cap = MAX_FLOW_RUNS_PER_FLOW; + for i in 0..cap { + let id = format!("run-{i:04}"); + insert_flow_run( + &config, + &id, + &flow.id, + &id, + &format!("2026-01-01T00:00:{i:02}Z"), + ) + .unwrap(); + finish_flow_run( + &config, + &id, + "completed", + "2026-01-01T00:01:00Z", + &[], + &[], + None, + None, + ) + .unwrap(); + } + assert_eq!( + list_flow_runs(&config, &flow.id, cap * 2).unwrap().len(), + cap + ); + + // One more insert should trigger the retention prune, keeping <= cap. + let extra = "run-extra"; + insert_flow_run(&config, extra, &flow.id, extra, "2026-01-02T00:00:00Z").unwrap(); + let count = list_flow_runs(&config, &flow.id, cap * 2).unwrap().len(); + assert!( + count <= cap, + "auto-prune should keep run count within cap ({count} > {cap})" + ); +} + +#[test] +fn list_flow_runs_respects_limit() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + for i in 0..3 { + let id = format!("run-{i}"); + insert_flow_run( + &config, + &id, + &flow.id, + &id, + &format!("2026-01-0{}T00:00:00Z", i + 1), + ) + .unwrap(); + } + + let limited = list_flow_runs(&config, &flow.id, 2).unwrap(); + assert_eq!(limited.len(), 2); +} + +// ── flow_suggestions ───────────────────────────────────────────────────────── + +fn sample_suggestion(id: &str, title: &str) -> FlowSuggestion { + FlowSuggestion { + id: id.to_string(), + title: title.to_string(), + one_liner: "does a useful thing".to_string(), + rationale: "grounded in your data".to_string(), + trigger_hint: Some("schedule".to_string()), + steps_outline: vec!["step one".to_string(), "step two".to_string()], + suggested_connections: vec!["composio:gmail:conn_1".to_string()], + suggested_slugs: vec!["GMAIL_SEND_EMAIL".to_string()], + build_prompt: "Build a workflow that…".to_string(), + confidence: 0.7, + status: SuggestionStatus::New, + created_at: "2026-07-05T00:00:00Z".to_string(), + source_run_id: Some("run-1".to_string()), + } +} + +#[test] +fn suggestions_upsert_list_roundtrip() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let written = upsert_suggestions( + &config, + &[ + sample_suggestion("s1", "Alpha"), + sample_suggestion("s2", "Beta"), + ], + ) + .unwrap(); + assert_eq!(written, 2); + + let all = list_suggestions(&config, Some(SuggestionStatus::New), 50).unwrap(); + assert_eq!(all.len(), 2); + // Round-trips the JSON-encoded vec columns. + let alpha = all.iter().find(|s| s.id == "s1").unwrap(); + assert_eq!(alpha.steps_outline.len(), 2); + assert_eq!(alpha.suggested_connections, vec!["composio:gmail:conn_1"]); + assert_eq!(alpha.suggested_slugs, vec!["GMAIL_SEND_EMAIL"]); + assert_eq!(alpha.trigger_hint.as_deref(), Some("schedule")); +} + +#[test] +fn upsert_suggestions_preserves_user_status_on_rerun() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + upsert_suggestions(&config, &[sample_suggestion("s1", "Alpha")]).unwrap(); + // User dismisses it. + assert!(set_suggestion_status(&config, "s1", SuggestionStatus::Dismissed).unwrap()); + + // A later discovery run re-proposes the identical idea (same id) with a + // refreshed pitch — the dismissal must survive. + let mut refreshed = sample_suggestion("s1", "Alpha (refined)"); + refreshed.status = SuggestionStatus::New; // agent always emits `New` + upsert_suggestions(&config, &[refreshed]).unwrap(); + + let dismissed = list_suggestions(&config, Some(SuggestionStatus::Dismissed), 50).unwrap(); + assert_eq!(dismissed.len(), 1); + assert_eq!(dismissed[0].title, "Alpha (refined)"); // pitch fields refreshed + // …but it is NOT back in the active `New` list. + let active = list_suggestions(&config, Some(SuggestionStatus::New), 50).unwrap(); + assert!(active.is_empty()); +} + +#[test] +fn set_suggestion_status_returns_false_for_unknown_id() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + assert!(!set_suggestion_status(&config, "missing", SuggestionStatus::Built).unwrap()); +} + +#[test] +fn list_suggestions_without_status_returns_all() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + upsert_suggestions(&config, &[sample_suggestion("s1", "Alpha")]).unwrap(); + set_suggestion_status(&config, "s1", SuggestionStatus::Built).unwrap(); + // Filtered to `New` → empty; unfiltered → present. + assert!(list_suggestions(&config, Some(SuggestionStatus::New), 50) + .unwrap() + .is_empty()); + assert_eq!(list_suggestions(&config, None, 50).unwrap().len(), 1); +} + +#[test] +fn upsert_suggestions_empty_is_noop() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + assert_eq!(upsert_suggestions(&config, &[]).unwrap(), 0); +} + +// ── Orphaned-running-run reconciliation (bug B42) ────────────────────────── + +#[test] +fn list_running_run_ids_returns_only_running_rows() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + insert_flow_run( + &config, + "run-live-1", + &flow.id, + "run-live-1", + "2026-01-01T00:00:00Z", + ) + .unwrap(); + insert_flow_run( + &config, + "run-live-2", + &flow.id, + "run-live-2", + "2026-01-01T00:00:01Z", + ) + .unwrap(); + insert_flow_run( + &config, + "run-done", + &flow.id, + "run-done", + "2026-01-01T00:00:02Z", + ) + .unwrap(); + finish_flow_run( + &config, + "run-done", + "completed", + "2026-01-01T00:00:03Z", + &[], + &[], + None, + None, + ) + .unwrap(); + + let mut running = list_running_run_ids(&config, "2099-01-01T00:00:00Z").unwrap(); + running.sort(); + assert_eq!( + running, + vec![ + ("run-live-1".to_string(), flow.id.clone()), + ("run-live-2".to_string(), flow.id.clone()), + ], + "only the two still-running rows must be listed, not the completed one" + ); +} + +#[test] +fn list_running_run_ids_excludes_rows_started_at_or_after_the_floor() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + insert_flow_run( + &config, + "run-old", + &flow.id, + "run-old", + "2026-01-01T00:00:00Z", + ) + .unwrap(); + insert_flow_run( + &config, + "run-at", + &flow.id, + "run-at", + "2026-01-01T00:00:05Z", + ) + .unwrap(); + insert_flow_run( + &config, + "run-new", + &flow.id, + "run-new", + "2026-01-01T00:00:09Z", + ) + .unwrap(); + + // The floor is exclusive: a row stamped exactly at the boot floor was + // inserted by THIS process (`start_flow_run_row` anchors the floor before + // stamping), so it must fall outside the candidate set along with newer + // rows — otherwise the sweep could interrupt a live run and drop its + // checkpoint mid-flight. + let running = list_running_run_ids(&config, "2026-01-01T00:00:05Z").unwrap(); + assert_eq!( + running, + vec![("run-old".to_string(), flow.id.clone())], + "only rows strictly older than the floor are sweep candidates" + ); +} + +#[test] +fn mark_run_interrupted_reconciles_a_running_row_with_reason() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + insert_flow_run(&config, "run-x", &flow.id, "run-x", "2026-01-01T00:00:00Z").unwrap(); + + let flipped = + mark_run_interrupted(&config, "run-x", "2026-01-01T00:05:00Z", "boom reason").unwrap(); + assert!(flipped, "a running row must be reconciled"); + + let row = get_flow_run(&config, "run-x").unwrap().unwrap(); + assert_eq!(row.status, "interrupted"); + assert_eq!(row.finished_at.as_deref(), Some("2026-01-01T00:05:00Z")); + assert_eq!(row.error.as_deref(), Some("boom reason")); +} + +#[test] +fn mark_run_interrupted_is_a_noop_for_a_terminal_row() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + insert_flow_run(&config, "run-y", &flow.id, "run-y", "2026-01-01T00:00:00Z").unwrap(); + finish_flow_run( + &config, + "run-y", + "completed", + "2026-01-01T00:00:01Z", + &[], + &[], + None, + None, + ) + .unwrap(); + + // The `status = 'running'` guard must protect an already-settled run. + let flipped = + mark_run_interrupted(&config, "run-y", "2026-01-01T00:05:00Z", "should not apply").unwrap(); + assert!( + !flipped, + "a completed run must never be clobbered to interrupted" + ); + + let row = get_flow_run(&config, "run-y").unwrap().unwrap(); + assert_eq!(row.status, "completed"); + assert!(row.error.is_none()); +} + +/// `expire_parked_runs` must return only the runs it ACTUALLY flipped, not the +/// candidates its `SELECT` saw. +/// +/// The `SELECT` and each row's guarded `UPDATE` are separate statements on an +/// autocommit connection, so a concurrent `mark_run_resuming` can claim a row in +/// between. The per-row `WHERE status = 'pending_approval'` keeps that row safe, +/// but returning the unfiltered candidate list would let the caller act on a run +/// it never expired — dropping the checkpoint out from under a live resume and +/// publishing a terminal `FlowRunFinished` for a run still executing. That false +/// event is the worse half: the frontend de-dupes terminal events per +/// `flow_id:run_id`, so the run's real completion would later be discarded. +#[test] +fn expire_parked_runs_returns_only_rows_it_actually_flipped() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "ttl".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + let stale_at = "2000-01-01T00:00:00+00:00"; + for id in ["claimed-run", "genuinely-stale-run"] { + insert_flow_run(&config, id, &flow.id, id, stale_at).unwrap(); + finish_flow_run( + &config, + id, + "pending_approval", + stale_at, + &[], + &["gate".to_string()], + None, + // No graph pin (T-M1): this fixture is about the TTL sweep's + // candidates-vs-sweeps behaviour, not stale-approval detection, so + // these rows stand in for pre-pin legacy parks. + None, + ) + .unwrap(); + } + + // Simulate the race: one candidate is claimed by a resume after the sweep's + // SELECT would have seen it, but before its UPDATE lands. + assert!(mark_run_resuming(&config, "claimed-run").unwrap()); + + let swept = expire_parked_runs( + &config, + "2099-01-01T00:00:00+00:00", + "2026-01-01T00:00:00+00:00", + "expired", + ) + .unwrap(); + + let swept_ids: Vec<&str> = swept.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!( + swept_ids, + vec!["genuinely-stale-run"], + "only the row whose guarded UPDATE matched may be reported as swept" + ); + assert_eq!( + get_flow_run(&config, "claimed-run") + .unwrap() + .unwrap() + .status, + "running", + "the claimed run must keep executing, untouched by the sweep" + ); + assert_eq!( + get_flow_run(&config, "genuinely-stale-run") + .unwrap() + .unwrap() + .status, + "cancelled" + ); +} + +// ── R-M4: corrupt/unmigratable graph_json rows must not brick a list ──────── + +#[test] +fn list_flows_skips_a_corrupt_row_and_reports_the_count() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let good_a = create_flow( + &config, + "good-a".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + let bad = create_flow( + &config, + "bad".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + let good_b = create_flow( + &config, + "good-b".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + force_corrupt_graph_json_for_test(&config, &bad.id, "{ not even valid json").unwrap(); + + let (flows, skipped) = list_flows(&config).unwrap(); + assert_eq!( + skipped, 1, + "exactly the one corrupt row must be counted as skipped" + ); + let ids: Vec<&str> = flows.iter().map(|f| f.id.as_str()).collect(); + assert_eq!( + flows.len(), + 2, + "the two good rows must still be returned: {ids:?}" + ); + assert!(ids.contains(&good_a.id.as_str())); + assert!(ids.contains(&good_b.id.as_str())); + assert!(!ids.contains(&bad.id.as_str())); +} + +#[test] +fn list_flows_skips_a_row_whose_schema_version_is_newer_than_this_build_supports() { + // The real-world R-M4 scenario: a user ran a newer build that persisted a + // graph at a `schema_version` this build's `tinyflows::migrate::migrate` + // cannot step backward from, then downgraded. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let good = create_flow( + &config, + "good".to_string(), + String::new(), + trigger_graph(), + false, diff --git a/src/openhuman/flows/store_tests_part_03.rs b/src/openhuman/flows/store_tests_part_03.rs new file mode 100644 index 0000000000..56296bdb80 --- /dev/null +++ b/src/openhuman/flows/store_tests_part_03.rs @@ -0,0 +1,538 @@ + true, + ) + .unwrap(); + let too_new = create_flow( + &config, + "too-new".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + let newer_schema_json = serde_json::json!({ + "schema_version": 999, + "name": "from-the-future", + "nodes": [], + "edges": [] + }) + .to_string(); + force_corrupt_graph_json_for_test(&config, &too_new.id, &newer_schema_json).unwrap(); + + let (flows, skipped) = list_flows(&config).unwrap(); + assert_eq!(skipped, 1); + assert_eq!(flows.len(), 1); + assert_eq!(flows[0].id, good.id); +} + +#[test] +fn list_enabled_flows_still_returns_the_good_rows_when_one_is_corrupt() { + // This is the blast-radius scenario R-M4 flags for `bus.rs::handle_app_event`: + // `list_enabled_flows` backs ALL `app_event` trigger dispatch, so one + // corrupt enabled flow must not blackhole matching for every other one. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let good = create_flow( + &config, + "good".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + let bad = create_flow( + &config, + "bad".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + force_corrupt_graph_json_for_test(&config, &bad.id, "not json at all").unwrap(); + + let (enabled, skipped) = list_enabled_flows(&config).unwrap(); + assert_eq!(skipped, 1); + assert_eq!(enabled.len(), 1); + assert_eq!(enabled[0].id, good.id); +} + +#[test] +fn list_enabled_flows_excludes_a_corrupt_disabled_row_without_counting_it_as_skipped() { + // A corrupt row that was never enabled must not even be attempted for + // decode by `list_enabled_flows` (the WHERE clause filters it out at the + // SQL layer before `map_flow_row` ever runs) — it is neither returned nor + // counted as skipped by this particular listing. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let good = create_flow( + &config, + "good".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + let disabled_and_corrupt = create_flow( + &config, + "disabled-bad".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + set_enabled(&config, &disabled_and_corrupt.id, false).unwrap(); + force_corrupt_graph_json_for_test(&config, &disabled_and_corrupt.id, "{{{").unwrap(); + + let (enabled, skipped) = list_enabled_flows(&config).unwrap(); + assert_eq!(skipped, 0); + assert_eq!(enabled.len(), 1); + assert_eq!(enabled[0].id, good.id); +} + +// ── R-m1: concurrent step upserts must not lose a step ────────────────────── + +#[test] +fn concurrent_step_upserts_do_not_lose_a_step() { + // Two observer callbacks for parallel branch nodes of the same run, + // racing to persist their step. Before the `BEGIN IMMEDIATE` fix this was + // a classic untransacted read-modify-write: both threads could read the + // same pre-write `steps_json`, and whichever `UPDATE` landed last would + // silently discard the other thread's step — permanently, since the + // post-hoc `settle_steps` reconstruction only refills a missing node with + // `status: None`, not its real outcome. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + let run_id = "run-concurrent"; + insert_flow_run(&config, run_id, &flow.id, run_id, "2026-01-01T00:00:00Z").unwrap(); + + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + + let config_a = config.clone(); + let barrier_a = barrier.clone(); + let handle_a = std::thread::spawn(move || { + barrier_a.wait(); + upsert_flow_run_step( + &config_a, + run_id, + &FlowRunStep { + node_id: "branch-a".to_string(), + output: serde_json::json!([{"json": {"a": 1}}]), + status: Some("success".to_string()), + ..Default::default() + }, + ) + }); + + let config_b = config.clone(); + let barrier_b = barrier.clone(); + let handle_b = std::thread::spawn(move || { + barrier_b.wait(); + upsert_flow_run_step( + &config_b, + run_id, + &FlowRunStep { + node_id: "branch-b".to_string(), + output: serde_json::json!([{"json": {"b": 1}}]), + status: Some("success".to_string()), + ..Default::default() + }, + ) + }); + + handle_a.join().unwrap().unwrap(); + handle_b.join().unwrap().unwrap(); + + let row = get_flow_run(&config, run_id).unwrap().unwrap(); + let node_ids: std::collections::HashSet<&str> = + row.steps.iter().map(|s| s.node_id.as_str()).collect(); + assert_eq!( + row.steps.len(), + 2, + "both concurrent steps must survive, none silently dropped: {:?}", + row.steps + ); + assert!(node_ids.contains("branch-a")); + assert!(node_ids.contains("branch-b")); +} + +#[test] +fn concurrent_upserts_to_the_same_node_id_do_not_corrupt_the_step_list() { + // Same run, same node_id, racing "replace" writes — the transaction must + // still leave exactly one entry for that node (whichever write wins the + // serialization order), never a torn/duplicated list. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow( + &config, + "demo".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + let run_id = "run-same-node"; + insert_flow_run(&config, run_id, &flow.id, run_id, "2026-01-01T00:00:00Z").unwrap(); + + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let mut handles = Vec::new(); + for i in 0..2 { + let config = config.clone(); + let barrier = barrier.clone(); + handles.push(std::thread::spawn(move || { + barrier.wait(); + upsert_flow_run_step( + &config, + run_id, + &FlowRunStep { + node_id: "same-node".to_string(), + output: serde_json::json!([{"json": {"attempt": i}}]), + status: Some("success".to_string()), + ..Default::default() + }, + ) + })); + } + for h in handles { + h.join().unwrap().unwrap(); + } + + let row = get_flow_run(&config, run_id).unwrap().unwrap(); + assert_eq!( + row.steps.len(), + 1, + "a re-upsert of the same node_id must replace, not duplicate: {:?}", + row.steps + ); + assert_eq!(row.steps[0].node_id, "same-node"); +} + +// ── R-m8: schema init is gated to once per process per database path ─────── + +#[test] +fn schema_initializes_correctly_on_a_fresh_database_and_is_idempotent_across_calls() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + // First-ever call against this database file in the process: exercises + // the full schema DDL (CREATE TABLE batch + indexes) plus the + // `require_approval` `add_column_if_missing` migration on a database that + // has never been opened before. + let flow = create_flow( + &config, + "fresh-db".to_string(), + String::new(), + trigger_graph(), + true, // require_approval + true, + ) + .unwrap(); + assert!( + flow.require_approval, + "the post-hoc require_approval column must exist and be writable on a brand-new db" + ); + + // Repeat calls against the SAME path must not need (or re-run) DDL — + // proves the cached "already initialized" state doesn't break ordinary + // reads/writes on reuse. + let (listed, skipped) = list_flows(&config).unwrap(); + assert_eq!(skipped, 0); + assert_eq!(listed.len(), 1); + assert!(listed[0].require_approval); + + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(reloaded.require_approval); + + let run_id = "run-schema-check"; + insert_flow_run(&config, run_id, &flow.id, run_id, "2026-01-01T00:00:00Z").unwrap(); + assert!(get_flow_run(&config, run_id).unwrap().is_some()); +} + +#[test] +fn schema_initializes_independently_for_each_distinct_database_path() { + // Regression guard for the once-per-process cache: if it were keyed by a + // single process-wide flag instead of by database path, opening a SECOND + // independent workspace after the first would silently skip schema + // creation and every write against it would fail with "no such table". + let tmp_a = TempDir::new().unwrap(); + let config_a = test_config(&tmp_a); + let flow_a = create_flow( + &config_a, + "a".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + let tmp_b = TempDir::new().unwrap(); + let config_b = test_config(&tmp_b); + let flow_b = create_flow( + &config_b, + "b".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + assert_eq!(list_flows(&config_a).unwrap().0.len(), 1); + assert_eq!(list_flows(&config_b).unwrap().0.len(), 1); + assert_eq!( + get_flow(&config_a, &flow_a.id).unwrap().unwrap().id, + flow_a.id + ); + assert_eq!( + get_flow(&config_b, &flow_b.id).unwrap().unwrap().id, + flow_b.id + ); +} + +/// R-m8 regression: gating the DDL behind a per-path "already initialized" set +/// must not cost the store its self-healing. +/// +/// Before the gate existed, the DDL ran on every `with_connection` call, so a +/// database deleted or replaced at runtime (workspace reset, manual deletion, +/// a restore) recovered on the very next call — `Connection::open` creates a +/// fresh empty file and `CREATE TABLE IF NOT EXISTS` repopulates it. With a +/// naive cache the set still reports "initialized" while the file behind it is +/// empty, and every query afterwards fails `no such table: flow_definitions` +/// until the process restarts. This pins the verify-on-hit that restores it. +#[test] +fn schema_reinitializes_when_the_database_file_is_deleted_at_runtime() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + // First use populates the per-path cache and creates the schema. + let flow = create_flow( + &config, + "before-deletion".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .unwrap(); + let (flows, _skipped) = list_flows(&config).unwrap(); + assert_eq!(flows.len(), 1, "sanity: the flow was persisted"); + + // Simulate a workspace reset / manual deletion while the process lives on. + let db_path = config.workspace_dir.join("flows").join("flows.db"); + assert!( + db_path.exists(), + "sanity: the flows db exists before deletion" + ); + std::fs::remove_file(&db_path).unwrap(); + // WAL sidecars must go too, or SQLite can resurrect pages from them. + let _ = std::fs::remove_file(db_path.with_extension("db-wal")); + let _ = std::fs::remove_file(db_path.with_extension("db-shm")); + + // The cache still says this path is initialized. Without the verify-on-hit + // this errors with `no such table: flow_definitions`. + let (flows_after, skipped_after) = list_flows(&config) + .expect("a deleted database must be re-initialized, not left wedged at 'no such table'"); + assert!( + flows_after.is_empty(), + "the recreated database starts empty — the prior flow is genuinely gone" + ); + assert_eq!(skipped_after, 0, "an empty database skips nothing"); + + // And the store is fully usable again, not merely readable. + let recreated = create_flow( + &config, + "after-deletion".to_string(), + String::new(), + trigger_graph(), + false, + true, + ) + .expect("writes must work against the re-initialized schema"); + assert_ne!(recreated.id, flow.id); + let (flows_final, _) = list_flows(&config).unwrap(); + assert_eq!(flows_final.len(), 1); +} + +// ── description: the field, and the upgrade path ────────────────────────── + +#[test] +fn a_description_round_trips_through_the_store() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let created = create_flow( + &config, + "Digest".to_string(), + "Posts the weekly digest to Slack.".to_string(), + trigger_graph(), + false, + true, + ) + .unwrap(); + let read_back = get_flow(&config, &created.id).unwrap().unwrap(); + assert_eq!(read_back.description, "Posts the weekly digest to Slack."); + // And through the list path, which uses a different SELECT. + let (flows, skipped) = list_flows(&config).unwrap(); + assert_eq!(skipped, 0); + assert_eq!(flows[0].description, "Posts the weekly digest to Slack."); +} + +#[test] +fn a_database_written_before_the_column_existed_still_opens() { + // The migration that matters. `add_column_if_missing` runs against a real + // pre-existing `flows.db`, so this builds one WITHOUT the column — exactly + // what an upgrading user has — and then opens it through the normal path. + // + // Constructed by hand rather than by checking in a fixture file: a binary + // fixture would drift silently as the rest of the schema moves, and the + // thing under test is one column, not the whole file format. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let db_path = tmp.path().join("workspace").join("flows").join("flows.db"); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE flow_definitions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + graph_json TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_run_at TEXT, + last_status TEXT + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO flow_definitions + (id, name, graph_json, enabled, created_at, updated_at) + VALUES ('old-1', 'Legacy flow', ?1, 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')", + rusqlite::params![serde_json::to_string(&trigger_graph()).unwrap()], + ) + .unwrap(); + } + + // Opening through the normal path must migrate, not fail. + let flow = get_flow(&config, "old-1") + .expect("an upgraded database must open") + .expect("the pre-existing row must survive"); + assert_eq!(flow.name, "Legacy flow"); + // The row predates the column, so it reads back empty — which every + // consumer already treats as "no description", not as corruption. + assert_eq!(flow.description, ""); +} + +#[test] +fn an_update_without_a_description_leaves_the_stored_one_alone() { + // The `COALESCE(?, description)` contract. An edit that only reshapes the + // graph must not silently blank the catalogue line. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let created = create_flow( + &config, + "Digest".to_string(), + "Posts the weekly digest.".to_string(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + let updated = update_flow_graph( + &config, + &created.id, + "Digest renamed".to_string(), + None, + trigger_graph(), + false, + None, + false, + None, + ) + .expect("update succeeds"); + assert_eq!(updated.name, "Digest renamed"); + assert_eq!(updated.description, "Posts the weekly digest."); +} + +#[test] +fn an_update_can_replace_and_can_clear_the_description() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let created = create_flow( + &config, + "Digest".to_string(), + "Original.".to_string(), + trigger_graph(), + false, + true, + ) + .unwrap(); + + let replaced = update_flow_graph( + &config, + &created.id, + "Digest".to_string(), + Some("Rewritten.".to_string()), + trigger_graph(), + false, + None, + false, + None, + ) + .unwrap(); + assert_eq!(replaced.description, "Rewritten."); + + // `Some("")` is the only way to say "clear it", and must work — otherwise + // a bad description is unfixable through this path. + let cleared = update_flow_graph( + &config, + &created.id, + "Digest".to_string(), + Some(String::new()), + trigger_graph(), + false, + None, + false, + None, + ) + .unwrap(); + assert_eq!(cleared.description, ""); +} + +#[test] +fn a_duplicate_carries_the_description_across() { + // A duplicate is the same automation under a new name; its purpose is + // unchanged, so an empty description on the copy would be a regression the + // user has to repair by hand. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let source = create_flow( + &config, + "Digest".to_string(), + "Posts the weekly digest.".to_string(), + trigger_graph(), + false, + true, + ) + .unwrap(); + let copy = insert_duplicate_flow(&config, &source, "Digest (copy)".to_string()).unwrap(); + assert_eq!(copy.description, "Posts the weekly digest."); +} diff --git a/src/openhuman/flows/tools.rs b/src/openhuman/flows/tools.rs index 37c95517a8..e52f51455c 100644 --- a/src/openhuman/flows/tools.rs +++ b/src/openhuman/flows/tools.rs @@ -48,80 +48,55 @@ impl Tool for ProposeWorkflowTool { } fn description(&self) -> &str { - "Propose a candidate automation workflow for the user to review and save. This tool \ - ONLY VALIDATES the graph and returns a summary — it NEVER creates or enables the flow; \ - the user must click \"Save & enable\" in the UI before anything is persisted or can \ - run. Build a tinyflows WorkflowGraph: nodes[] ({id, kind, name, config}) + edges[] \ - ({from_node, to_node, from_port?, to_port?}; ports default \"main\"). For a branching \ - node (condition/switch), the branch label goes on from_port — NEVER on to_port \ - (to_port just stays \"main\"); routing is keyed exclusively on from_port, so a label \ - on to_port instead silently turns the branch into an unconditional fan-out and is a \ - hard reject. Exactly ONE \ - trigger node is required. The 22 node kinds: trigger (config.trigger_kind: manual | \ - schedule | webhook | app_event | form | chat_message | evaluation | system | \ - execute_by_workflow; schedule needs config.schedule = {kind:\"cron\",expr,tz?} | \ - {kind:\"at\",at} | {kind:\"every\",every_ms}; app_event needs config.toolkit + \ - config.trigger_slug), agent (config.prompt), tool_call (config.slug REQUIRED + \ - config.args), http_request (config.method/url, optional headers/body), code \ - (config.language: \"javascript\"|\"python\" + config.source), shell (exactly one of \ - config.source or config.script_path; optional config.interpreter: \"sh\"|\"bash\", \ - config.cwd, config.env; this host rejects execution until it has a policy-aware shell \ - capability), condition (config.field; \ - routes on from_port \"true\"/\"false\", e.g. {from_node:\"gate\",from_port:\"true\",\ - to_node:\"x\",to_port:\"main\"}), switch (config.expression or config.field; routes to \ - the matching case port, or \"default\"), transform (config.set: {key: \"=expr\"} \ - merged onto each item), split_out (config.path to an array field; fans out one item per \ - element), merge (fan-in passthrough, no config), output_parser (passthrough today; no \ - config required), sub_workflow (config.workflow: an embedded child WorkflowGraph), \ - memory (config.operation REQUIRED: recall | search | flavour | people | remember | \ - forget; config.scope for recall/remember/forget: \"user\" is READ-ONLY, \"flow\" is \ - this flow's own memory and the ONLY scope remember/forget may target, \"flows\" is \ - cross-flow READ-ONLY; config.query for recall/search; config.flavour for the flavour \ - slug; config.key/config.value for remember/forget. Place remember AFTER the real \ - action it records, never before, so a failed action never marks an item as done), \ - dedup (config.key REQUIRED: an \"=expr\" per-item key, e.g. \"=item.id\"; drops an item \ - whose key was already committed by a PRIOR successful run, else passes it through. Use \ - this — not a memory recall/condition graph — for exact \"process each item once\": \ - place it right after the item source and before the action, e.g. split_out → dedup → \ - …action…), \ - loop (optional config.max_iterations, positive, default 25; optional config.on_exceeded: \ - \"error\" (default, fails the run) | \"continue\" (stop looping, leave via `done`); \ - optional config.condition \"=expr\" for an early exit. Emits on the `body` port while \ - it keeps looping and on `done` when it stops; CLOSE THE LOOP by wiring the body's last \ - node back to the loop node. The pass number is readable as \ - \"=nodes..iteration\". The `body` must ROUTE BACK to the loop node, not merely \ - leave it. A fan-in `merge` must not sit on the cycle, and the loop node must not itself \ - be a fan-in — join before it instead), \ - spawn (config.target REQUIRED: workflow | tool | http — starts work WITHOUT waiting \ - and emits an opaque ticket, so the branch carries on; target=tool needs config.slug + \ - config.args, target=workflow needs config.workflow, target=http needs config.request. \ - Pass the ticket to a `gate`; never interpret it. A spawn no gate collects simply runs \ - — wire it into a `void` to say that on purpose), \ - gate (the collecting half of spawn: config.from = ids of the spawn nodes to wait on, \ - or config.tickets \"=expr\"; optional config.release: \"all\" (default) | \"any\" | \ - \"first_n\" | \"quorum\" | \"timeout_partial\", with config.n REQUIRED and > 0 for \ - first_n/quorum; optional config.wait_mode: \"poll\" (default) | \"suspend\". EVERY \ - poll costs a super-step, so a long wait wants \"suspend\", not a big max_polls), \ - scatter (fans the whole DOWNSTREAM PATH into parallel lanes, not just the immediate \ - successors: scatter → enrich → score → gather runs that pipeline once per lane. \ - Optional config.path to an array to fan out over, else the node's own input items are \ - the lanes; optional config.lanes to chunk into at most N lanes, clamped to 256. MUST \ - reach a `gather`, and lane nodes are read as \"=nodes..lanes.\", NOT \ - \"=nodes..item\". A nested scatter, a loop head, or requires_approval inside a \ - lane are each rejected), \ - gather (collects the lanes: config.from REQUIRED = ids of the lane-terminal nodes; same \ - config.release/config.n policies as gate; optional config.on_lane_error: \"collect\" \ - (default) | \"skip\" | \"fail_fast\". Output is ordered by lane index, not by finish \ - order), \ - approval (a HUMAN review step, distinct from requires_approval: optional config.subject \ - or \"=expr\", config.subject_kind, config.title, config.prompt, config.assignees, and \ - config.metadata; routes the verdict as data on \"approved\" / \"rejected\". With no \ - host review provider it parks the run and is settled through flows_resume), \ - void (terminal sink, no config: accepts items, discards them, runs nothing downstream. \ - Says \"this branch is a side effect and nothing waits on it\" where an unwired port \ - would read like a forgotten one. An outgoing edge is a hard reject, and so is having \ - no incoming edge; it adds NO concurrency — use spawn for that). If \ - validation fails, fix the graph and call this tool again." + // Generated once, not hand-written. + // + // This description used to carry a 5,841-byte copy of the node-kind + // reference: every kind, its required *and* optional config, and a + // paragraph of gotchas each. That is the third copy of the same + // material — `get_node_kind_contract` serves it authoritatively and + // `workflow_builder`'s prompt carries an index — and it shipped on + // every request of every agent holding this tool. + // + // `render_node_kinds_required()` reduces it to the one thing a caller + // cannot recover from a failed call: which kinds exist and what config + // each cannot be built without (401 bytes). Everything else is one + // `get_node_kind_contract { kind }` away. + // + // Generating it also retires a drift class rather than testing for it. + // `propose_workflow_description_matches_typed_node_contracts` existed + // because a hand-written copy could fall behind `node_contracts.rs`; + // it now passes by construction, and stays as the regression guard for + // anyone tempted to hand-write this again. + static DESCRIPTION: std::sync::OnceLock = std::sync::OnceLock::new(); + DESCRIPTION + .get_or_init(|| { + format!( + "Propose a candidate automation workflow for the user to review and save. \ + This tool ONLY VALIDATES the graph and returns a summary — it NEVER creates \ + or enables the flow; the user must click \"Save & enable\" in the UI before \ + anything is persisted or can run. If validation fails, fix the graph and call \ + this tool again.\n\ + \n\ + Build a tinyflows WorkflowGraph: nodes[] ({{id, kind, name, config}}) + \ + edges[] ({{from_node, to_node, from_port?, to_port?}}; ports default \ + \"main\"). Exactly ONE trigger node is required.\n\ + \n\ + Branching (condition/switch): the branch label goes on from_port, NEVER on \ + to_port (which stays \"main\"). Routing is keyed exclusively on from_port, so \ + a label on to_port silently turns the branch into an unconditional fan-out \ + and is a hard reject.\n\ + \n\ + A memory node may only target config.scope \"flow\" for remember/forget; \ + \"user\" is READ-ONLY and a write to it is a hard reject.\n\ + \n\ + Node kinds and their required config: {kinds}.\n\ + Call `get_node_kind_contract {{ kind }}` for a kind's optional fields, ports, \ + a worked example, and its gotchas — it is generated from the catalog the \ + validator enforces, so it is always current.", + kinds = crate::openhuman::flows::render_node_kinds_required() + ) + }) + .as_str() } fn parameters_schema(&self) -> Value { diff --git a/src/openhuman/flows/tools_tests.rs b/src/openhuman/flows/tools_tests.rs index 5c4283e210..8064e2aab5 100644 --- a/src/openhuman/flows/tools_tests.rs +++ b/src/openhuman/flows/tools_tests.rs @@ -458,6 +458,7 @@ async fn propose_workflow_rejects_an_incompatible_saved_child_reference() { let child = crate::openhuman::flows::store::create_flow( &config, "Legacy unsafe child".to_string(), + String::new(), child_graph, false, false, diff --git a/src/openhuman/flows/types.rs b/src/openhuman/flows/types.rs new file mode 100644 index 0000000000..6891a375bb --- /dev/null +++ b/src/openhuman/flows/types.rs @@ -0,0 +1,493 @@ +//! The [`Flow`] entity: a saved automation workflow definition. +//! +//! Wraps `tinyflows::model::WorkflowGraph` with the metadata OpenHuman needs to +//! store, list, and track runs for a saved flow. The graph itself is the +//! portable, tinyflows-owned contract (validated + migrated on load); this +//! struct is the OpenHuman-side record around it. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tinyflows::model::WorkflowGraph; + +/// How a flow run was started. Stamped onto the run's Langfuse trace as a +/// `trigger:` tag plus `trigger` metadata so runs can be filtered by +/// origin in the Langfuse UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlowRunTrigger { + /// An explicit run request over RPC/CLI (the Workflows UI "Run" button). + Rpc, + /// A `FlowScheduleTick` cron dispatch (`schedule` trigger node). + Schedule, + /// A `ComposioTriggerReceived` dispatch (`app_event` trigger node). + AppEvent, + /// A human-in-the-loop resume of a paused run (`flows_resume`). + Resume, +} + +impl FlowRunTrigger { + /// Stable snake_case identifier used in Langfuse tags/metadata. + pub fn as_str(&self) -> &'static str { + match self { + FlowRunTrigger::Rpc => "rpc", + FlowRunTrigger::Schedule => "schedule", + FlowRunTrigger::AppEvent => "app_event", + FlowRunTrigger::Resume => "resume", + } + } +} + +/// The result of validating a candidate `tinyflows` graph without persisting +/// it — returned by `openhuman.flows_validate` (PHASE 3c) and used to surface +/// structural errors and non-fatal warnings (e.g. "this trigger kind never +/// fires automatically yet") to an authoring surface *before* a flow is saved. +/// +/// A graph is `valid` when it passes `tinyflows::validate::validate_all` after +/// migration; `errors` carries **every** structural error when it does not (a +/// pre-validation failure — unparseable JSON or an unmigrateable schema — is +/// still a single entry). `warnings` is orthogonal to validity — a `valid` +/// graph can still carry warnings (it saves and enables fine, it just won't +/// behave as an author might expect), and an invalid graph reports no warnings +/// (there's nothing to warn about a graph that won't compile). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct FlowValidation { + /// True when the graph is structurally valid (migrates + validates). + pub valid: bool, + /// Human-readable structural validation errors (empty when `valid`). As of + /// the multi-error work this carries **all** independent structural + /// problems in one pass — an author fixing five costs one validate call, + /// not five round-trips. See [`FlowValidation::error_details`] for the + /// machine-readable, per-node form. + pub errors: Vec, + /// Structured, machine-readable counterpart to [`FlowValidation::errors`]: + /// one entry per structural error, carrying a stable `code`, the anchoring + /// `node_id` when node-specific, and the human `message`. Additive and + /// `#[serde(default)]` so existing clients that only read `errors` are + /// unaffected; agent tools and richer UIs consume this to attach errors to + /// the right node and switch on `code`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub error_details: Vec, + /// Non-fatal warnings: the graph is accepted, but something about it is + /// worth flagging (e.g. an unfired trigger kind). Never blocks save/enable. + pub warnings: Vec, +} + +/// A single structural validation error in machine-readable form — the +/// structured counterpart to a [`FlowValidation::errors`] string. +/// +/// Mirrors `tinyflows::error::ValidationError` (via its `code()` / `node_id()` +/// accessors) so a host surface can attach the error to a specific node and +/// switch on a stable `code` rather than parsing the `message`. `field` is +/// reserved for future config-level errors that can name the offending config +/// key; it is `None` for today's graph/edge-level checks. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct FlowValidationError { + /// Stable, machine-readable identifier for the error kind (e.g. + /// `missing_trigger`, `unknown_node`, `invalid_condition_routing`). + pub code: String, + /// Human-readable description — identical to the matching + /// [`FlowValidation::errors`] string. + pub message: String, + /// The node id this error is anchored to, when node-specific; `None` for + /// graph-wide errors (missing trigger, schema-too-new, multiple triggers). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_id: Option, + /// The offending config field, when the error is config-key-specific. + /// Reserved for future use; `None` today. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field: Option, +} + +/// The result of importing a workflow definition (native tinyflows JSON or an +/// n8n export) via `openhuman.flows_import` (PHASE 4d) — the normalized, +/// migrated + validated [`WorkflowGraph`] plus any non-fatal import warnings +/// (unmapped n8n node types, untranslated expressions, a synthesized/demoted +/// trigger, …). +/// +/// **Import never persists.** This is the same contract as +/// [`FlowValidation`]: the graph comes back ready for the editable canvas as a +/// *draft*, and only the user's explicit Save (the existing `flows_create` +/// gate) writes it. A structurally invalid graph is reported as an `Err` on the +/// RPC (validation is authoritative), not as an `FlowImport` with `valid: +/// false` — there is no partial-import row. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FlowImport { + /// The normalized workflow graph, migrated to the current schema and + /// structurally validated. Ready to open on the canvas as an unsaved draft. + pub graph: WorkflowGraph, + /// Non-fatal import warnings surfaced next to the draft. Empty for a clean + /// native import; an n8n import populates it with any approximations made. + pub warnings: Vec, +} + +/// A snapshot of a flow's graph captured just before an update overwrote it — +/// the safety rail behind `flows_rollback` / `get_flow_history` (audit F6). +/// +/// Rows live in the `flow_revisions` table, capped (e.g. last 20 per flow). +/// `graph` is the prior graph as raw JSON so a snapshot never fails to load +/// even if the schema later evolves. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FlowRevision { + /// Stable revision id (UUID). + pub id: String, + /// The flow this snapshot belongs to. + pub flow_id: String, + /// The flow's graph at the time this revision was captured (raw JSON). + pub graph: Value, + /// The flow's name at capture time. + pub name: String, + /// The flow's `require_approval` at capture time. + pub require_approval: bool, + /// RFC3339 time the snapshot was captured (i.e. when it was superseded). + pub created_at: String, +} + +/// Where a [`FlowDraft`] came from — carried through so the UI can label a +/// draft and the agent can reason about it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DraftOrigin { + /// Created from a chat/copilot build turn. + Chat, + /// Created from the canvas (e.g. "new workflow", or an accepted proposal). + Canvas, + /// Created from an import (native tinyflows JSON or an n8n export). + Import, +} + +impl DraftOrigin { + /// The serde wire discriminator, for logging. + pub fn as_str(&self) -> &'static str { + match self { + Self::Chat => "chat", + Self::Canvas => "canvas", + Self::Import => "import", + } + } +} + +/// A durable, core-managed **draft** of a workflow graph — the shared working +/// copy the agent tools and the canvas both read/write across turns and reloads +/// (audit F5). +/// +/// Stored as a plain JSON file on disk (`{workspace_dir}/flows/drafts/.json`), +/// not in SQLite — trivially inspectable and deletable, no schema/migration. +/// A draft is **never live**: promoting it (`flows_draft_promote`) runs the +/// existing `flows_create`/`flows_update` gates (same forced `require_approval` +/// floor, same human-in-the-loop) and removes the file. `graph` is a raw JSON +/// value (not a typed `WorkflowGraph`) because a work-in-progress draft is +/// explicitly allowed to be incomplete or not-yet-valid. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FlowDraft { + /// Stable draft id (UUID). Distinct from any `flow_id`. + pub id: String, + /// The saved flow this draft edits, if any. `None` for a from-scratch draft + /// (promote → `flows_create`); `Some` for an edit of an existing flow + /// (promote → `flows_update`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flow_id: Option, + /// Human-readable name carried into the flow on promote. + pub name: String, + /// The work-in-progress graph as raw JSON (may be incomplete/invalid). + pub graph: Value, + /// Where the draft originated. + pub origin: DraftOrigin, + /// RFC3339 creation time. + pub created_at: String, + /// RFC3339 last-update time. + pub updated_at: String, +} + +/// A saved automation workflow: a `tinyflows` graph plus OpenHuman-side +/// bookkeeping (enablement, run history summary). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Flow { + /// Stable identifier (UUID) for this flow. + pub id: String, + /// Human-readable name shown in the Workflows UI. + pub name: String, + /// One line saying what this automation is *for*, authored by whoever + /// built it. + /// + /// Lives here rather than on `tinyflows::model::WorkflowGraph` for the + /// same reason [`Flow::name`] does: it is catalogue metadata about a saved + /// automation, not part of the executable graph, and the engine never + /// reads it. Keeping it host-side also means the vendored crate does not + /// have to change for a field only this catalogue consumes. + /// + /// Empty is a real and common state — every flow saved before this field + /// existed has one, and the builder does not force a description. Readers + /// must handle that rather than rendering a blank line; + /// `flows::catalogue` falls back to describing the graph's shape. + #[serde(default)] + pub description: String, + /// Whether this flow may currently be triggered (B2) / run. + pub enabled: bool, + /// The validated, migrated workflow graph. + pub graph: WorkflowGraph, + /// RFC3339 creation timestamp. + pub created_at: String, + /// RFC3339 last-update timestamp. + pub updated_at: String, + /// RFC3339 timestamp of the most recent run, if any. + pub last_run_at: Option, + /// Outcome of the most recent run: `"completed"` | `"pending_approval"` | `"failed"`. + pub last_status: Option, + /// "Require approval for outbound actions" (issue B2). When `true`, the + /// approval gate does NOT auto-allow this flow's `TrustedAutomation + /// { Workflow }` trust root — every external_effect tool/HTTP call the + /// flow makes still parks for a real decision, regardless of how the run + /// was triggered. See `src/openhuman/security/approval/gate.rs` and + /// `src/openhuman/agent/turn_origin.rs::TrustedAutomationSource::Workflow`. + #[serde(default)] + pub require_approval: bool, +} + +/// One step of a persisted [`FlowRun`] (run-history inspector). +/// +/// As of issue G2 (live run observation) these are persisted **incrementally** +/// as each non-trigger node finishes, by +/// `flows::observability::FlowRunObserver::on_step_finish`, which maps a live +/// `tinyflows::observability::ExecutionStep` (carrying real `status` + +/// `duration_ms`) onto this type. The prior post-hoc reconstruction from +/// `RunOutcome.output["nodes"]` (see `flows::ops::reconstruct_steps`) now only +/// fills in steps the observer missed (e.g. a trigger node, which does not +/// emit an `on_step_finish`) — those carry no `status`/`duration_ms` and keep +/// the `port` the reconstruction recovers. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct FlowRunStep { + /// The node's id within the flow's graph. + pub node_id: String, + /// The node's emitted items for this run (`output["nodes"][id]["items"]`, + /// or the live `ExecutionStep.output` when observed incrementally). + pub output: serde_json::Value, + /// The output port the node routed on, if it picked one (branching / + /// switch nodes) — `output["nodes"][id]["port"]`. Only recovered by the + /// post-hoc reconstruction; the live observer does not carry a port. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port: Option, + /// Live step outcome, when this step was observed incrementally: + /// `"success"` | `"error"`. `None` for a step recovered post-hoc. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Wall-clock duration of the node's executor in milliseconds, when + /// observed incrementally. `None` for a step recovered post-hoc. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + /// Data-binding diagnostics from the engine: each config `=`-expression + /// that resolved to `null` during this step, as + /// `{ "location": "args.to", "expression": "=item.to" }`. Lets the run + /// view point at the exact unresolved wiring. Empty for clean steps and + /// for steps recovered post-hoc. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +/// A resolvable connection the flows UI / agent picker can attach to a node's +/// `connection_ref`. Aggregated by `openhuman.flows_list_connections` from two +/// host-side sources: +/// +/// - **Composio connected accounts** (`kind = "composio"`) — each active OAuth +/// integration instance, emitted as a ready-to-use +/// `"composio::"` ref (the exact shape +/// `tinyflows::caps::composio_connection_id` parses back on execution). +/// - **Named HTTP credentials** (`kind = "http"`) — each stored injection +/// template, emitted as `"http_cred:"` (the shape +/// `tinyflows::caps::http_cred_name` parses). +/// +/// **Security contract:** carries only non-secret identity — the +/// `connection_ref` string plus a display label (and toolkit/scheme hints). +/// It NEVER carries secret material (OAuth tokens, bearer tokens, passwords, +/// API keys). Those stay server-side and are injected only inside the +/// `tinyflows::caps` adapters at execution time. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FlowConnection { + /// The ready-to-use `connection_ref` value to stamp onto a node: + /// `"composio::"` or `"http_cred:"`. + pub connection_ref: String, + /// Source kind: `"composio"` | `"http"`. + pub kind: String, + /// Human-readable label for the picker, e.g. `"Gmail · user@example.com"` + /// or `"stripe (bearer)"`. Never contains secret material. + pub display: String, + /// Composio toolkit slug (`kind = "composio"` only), e.g. `"gmail"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub toolkit: Option, + /// HTTP credential injection scheme (`kind = "http"` only): + /// `"bearer"` | `"basic"` | `"header"`. Not a secret. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheme: Option, + /// The connected account's own platform user id (`kind = "composio"` + /// only), e.g. Slack's `"U123ABC"` — resolved from the provider profile + /// synced via `SLACK_TEST_AUTH`/auth.test on connection sync (see + /// `memory_sync::composio::providers::profile::load_connected_identities`). + /// Non-secret identity metadata: lets the workflow builder wire a + /// self-targeted action (e.g. "DM me") to the user's own account instead + /// of guessing a public channel. `None` when no identity has synced yet. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform_user_id: Option, +} + +/// A persisted record of one `flows_run` / `flows_resume` invocation, for the +/// B3 run-history inspector. Written by `flows::store` from `flows::ops`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowRun { + /// Stable identifier for this run — the same value as `thread_id` (the + /// tinyflows checkpointer key), so a run row can be found either way. + pub id: String, + /// The flow this run belongs to. + pub flow_id: String, + /// The tinyflows checkpointer thread id (needed to `flows_resume`). + pub thread_id: String, + /// Run status. Not an enum (kept a free-form `String` for forward-compat + /// with statuses added by newer builds), but the vocabulary is fixed: + /// `"running"` | `"completed"` | `"completed_with_warnings"` | + /// `"pending_approval"` | `"failed"` | `"cancelled"` (issue G4 — a run + /// cancelled via `flows_cancel_run`, or a parked `pending_approval` run + /// swept by the TTL expiry) | `"interrupted"` (bug B42 — a run whose future + /// was dropped mid-flight, reconciled either by the in-process + /// `RunRowFinalizer` drop-guard or the boot-time orphan sweep, so a + /// cancelled/timed-out/crashed run always settles to a terminal row instead + /// of wedging at `running`). `"completed_with_warnings"` (run honesty, + /// PR2) is a terminal status like `"completed"`, but at least one settled + /// [`FlowRunStep`] carries non-empty `diagnostics` (a `=`-binding that + /// resolved to `null`) even though no step outright errored. All of + /// `completed` / `completed_with_warnings` / `failed` / `cancelled` / + /// `interrupted` are terminal. + pub status: String, + /// RFC3339 timestamp when the run started. + pub started_at: String, + /// RFC3339 timestamp when the run last settled — stamped for every terminal + /// status (completed/paused/failed/cancelled/`"interrupted"`; the B42 + /// drop-guard and boot sweep stamp it exactly like a normal terminal + /// write). `None` only while a run row is still `"running"`. + pub finished_at: Option, + /// Reconstructed per-node steps (see [`FlowRunStep`]). + #[serde(default)] + pub steps: Vec, + /// Node ids paused awaiting human approval when `status == + /// "pending_approval"`; empty otherwise. + #[serde(default)] + pub pending_approvals: Vec, + /// Human-readable failure reason. Set when `status == "failed"`, and also + /// when `status == "interrupted"` (bug B42) — there it carries the + /// reconciliation reason (tool abort / chat turn end / app restart) so the + /// run-details sidebar can explain *why* the run stopped instead of + /// rendering a bare terminal state. + #[serde(default)] + pub error: Option, + /// Content hash of the graph this run was executing when it parked at + /// `status == "pending_approval"` (T-M1 — stale-approval guard). `None` + /// for a run that never parked, or for a row written before this pin + /// existed (a legacy `pending_approval` row) — `flows_resume` treats a + /// `None` on a currently-parked row as "unknown, allow with a warning" + /// rather than a hard refusal, so upgrading mid-park cannot strand an + /// in-flight approval. See `flows::ops::compute_graph_hash` and + /// `flows_resume`'s doc for the full mechanics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graph_hash: Option, +} + +/// Lifecycle status of a [`FlowSuggestion`] discovery card. +/// +/// A freshly discovered suggestion starts `New`. The user can `Dismiss` it (it +/// stays persisted so a later discovery run can dedupe against it and won't +/// re-surface a rejected idea) or act on it — once the suggestion's flow is +/// actually saved via `flows_create`, the frontend marks it `Built`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum SuggestionStatus { + /// Freshly discovered, awaiting the user's decision. The default. + #[default] + New, + /// The user dismissed the card; kept for dedupe, never re-surfaced. + Dismissed, + /// The user built (saved) a flow from this suggestion. + Built, +} + +impl SuggestionStatus { + /// The stable lowercase token persisted in SQLite / crossed over RPC. + pub fn as_str(self) -> &'static str { + match self { + Self::New => "new", + Self::Dismissed => "dismissed", + Self::Built => "built", + } + } + + /// Parse a persisted/RPC token back into a status. Unknown tokens fall + /// back to [`SuggestionStatus::New`] (forward-compatible with any status a + /// newer build might persist), so a stale row never hard-errors a read. + pub fn from_str_lossy(s: &str) -> Self { + match s { + "dismissed" => Self::Dismissed, + "built" => Self::Built, + _ => Self::New, + } + } +} + +/// A concrete, buildable workflow idea proposed by the `flow_discovery` agent +/// (the "Flow Scout"). Persisted to the `flow_suggestions` table and surfaced +/// as a card in the Flows page "Suggested for you" section. +/// +/// **Not a graph.** A suggestion is a *pitch* the user can accept, not a +/// validated [`WorkflowGraph`]. Its [`Self::build_prompt`] is the natural-language +/// brief handed to the `workflow_builder` agent when the user clicks "Build +/// this"; that agent turns it into a real graph proposal for the user to save. +/// This keeps the discovery agent read-only and the authoring pipeline unchanged. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FlowSuggestion { + /// Stable identifier (a content hash of the normalized title, so re-running + /// discovery dedupes identical ideas rather than piling duplicates). + pub id: String, + /// Short, human-friendly title, e.g. `"Auto-file email receipts"`. + pub title: String, + /// One-sentence description of what the workflow would do, e.g. + /// `"When a Gmail receipt arrives, add a row to your expenses sheet."` + pub one_liner: String, + /// Why this is being suggested to *this* user — grounded in what the agent + /// observed (a recurring thread, a stated goal in memory, a connected app), + /// e.g. `"You forward receipts to yourself most weeks."` + pub rationale: String, + /// Which trigger the workflow would likely use, as a hint for the card and + /// the builder: `"schedule"` | `"app_event"` | `"manual"` (free-form; only + /// those three self-fire in this host). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trigger_hint: Option, + /// Plain-language outline of the steps, one per element, e.g. + /// `["Watch Gmail for receipts", "Extract amount + vendor", "Append a Sheet row"]`. + #[serde(default)] + pub steps_outline: Vec, + /// `connection_ref` values the agent grounded against real + /// `flows_list_connections` output (never invented), so the card can show + /// "uses your Gmail" and the builder can stamp them verbatim. + #[serde(default)] + pub suggested_connections: Vec, + /// Real Composio action slugs the agent grounded via `search_tool_catalog` + /// (never hallucinated). Empty when the workflow is HTTP/agent-only. + #[serde(default)] + pub suggested_slugs: Vec, + /// The natural-language brief handed to `workflow_builder` on "Build this". + /// Self-contained: trigger + steps + connections, enough for the builder to + /// author a graph without re-deriving the idea. + pub build_prompt: String, + /// Agent's self-rated confidence in `[0.0, 1.0]` that this is a genuinely + /// useful, buildable automation for the user — used to rank cards. + #[serde(default)] + pub confidence: f64, + /// Lifecycle status (see [`SuggestionStatus`]). + #[serde(default)] + pub status: SuggestionStatus, + /// RFC3339 timestamp when the suggestion was first discovered. + pub created_at: String, + /// The `flows_discover` run that produced this suggestion (correlation for + /// observability); `None` for suggestions authored outside a tracked run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_run_id: Option, +} + +#[cfg(test)] +#[path = "types_tests.rs"] +mod tests; diff --git a/src/openhuman/flows/types_tests.rs b/src/openhuman/flows/types_tests.rs new file mode 100644 index 0000000000..0fe8cdb33d --- /dev/null +++ b/src/openhuman/flows/types_tests.rs @@ -0,0 +1,161 @@ +use super::*; + use tinyflows::model::{Node, NodeKind}; + + fn sample_graph() -> WorkflowGraph { + WorkflowGraph { + nodes: vec![Node { + id: "t".to_string(), + kind: NodeKind::Trigger, + type_version: 1, + name: "Trigger".to_string(), + config: serde_json::Value::Null, + ports: Vec::new(), + position: None, + }], + ..Default::default() + } + } + + #[test] + fn flow_round_trips_through_json() { + let flow = Flow { + id: "flow_1".to_string(), + name: "demo".to_string(), + description: "Round-trips through JSON.".to_string(), + enabled: true, + graph: sample_graph(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + last_run_at: None, + last_status: None, + require_approval: false, + }; + let json = serde_json::to_string(&flow).expect("serialize"); + let back: Flow = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.id, flow.id); + assert_eq!(back.graph, flow.graph); + assert!(back.last_run_at.is_none()); + assert!(!back.require_approval); + } + + #[test] + fn flow_require_approval_defaults_false_when_omitted_from_json() { + // Legacy/serialized JSON authored before the field existed must still + // deserialize (SQLite rows are migrated via `add_column_if_missing`, + // but any bare JSON fixture should also default safely). + let json = serde_json::json!({ + "id": "flow_1", + "name": "demo", + "enabled": true, + "graph": sample_graph(), + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + }); + let flow: Flow = serde_json::from_value(json).expect("deserialize"); + assert!(!flow.require_approval); + } + + #[test] + fn flow_run_round_trips_through_json() { + let run = FlowRun { + id: "flow:flow_1:run-uuid".to_string(), + flow_id: "flow_1".to_string(), + thread_id: "flow:flow_1:run-uuid".to_string(), + status: "completed".to_string(), + started_at: "2026-01-01T00:00:00Z".to_string(), + finished_at: Some("2026-01-01T00:00:01Z".to_string()), + steps: vec![FlowRunStep { + node_id: "t".to_string(), + output: serde_json::json!([{"json": {"hello": "world"}}]), + port: None, + ..Default::default() + }], + pending_approvals: Vec::new(), + error: None, + graph_hash: None, + }; + let json = serde_json::to_string(&run).expect("serialize"); + let back: FlowRun = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.id, run.id); + assert_eq!(back.steps.len(), 1); + assert_eq!(back.steps[0].node_id, "t"); + assert!(back.steps[0].port.is_none()); + } + + #[test] + fn flow_run_step_omits_port_when_none() { + let step = FlowRunStep { + node_id: "n".to_string(), + output: serde_json::Value::Null, + port: None, + ..Default::default() + }; + let v = serde_json::to_value(&step).unwrap(); + assert!(v.get("port").is_none()); + } + + #[test] + fn suggestion_status_token_round_trips() { + for st in [ + SuggestionStatus::New, + SuggestionStatus::Dismissed, + SuggestionStatus::Built, + ] { + assert_eq!(SuggestionStatus::from_str_lossy(st.as_str()), st); + } + // Unknown tokens fall back to New rather than erroring. + assert_eq!( + SuggestionStatus::from_str_lossy("something_new"), + SuggestionStatus::New + ); + assert_eq!(SuggestionStatus::default(), SuggestionStatus::New); + } + + #[test] + fn flow_suggestion_round_trips_through_json() { + let s = FlowSuggestion { + id: "sug_abc".to_string(), + title: "Auto-file email receipts".to_string(), + one_liner: "When a Gmail receipt arrives, add a row to your expenses sheet." + .to_string(), + rationale: "You forward receipts to yourself most weeks.".to_string(), + trigger_hint: Some("app_event".to_string()), + steps_outline: vec![ + "Watch Gmail for receipts".to_string(), + "Extract amount + vendor".to_string(), + ], + suggested_connections: vec!["composio:gmail:conn_1".to_string()], + suggested_slugs: vec!["GMAIL_NEW_GMAIL_MESSAGE".to_string()], + build_prompt: "Build a workflow that…".to_string(), + confidence: 0.82, + status: SuggestionStatus::New, + created_at: "2026-07-05T00:00:00Z".to_string(), + source_run_id: Some("run-1".to_string()), + }; + let json = serde_json::to_string(&s).expect("serialize"); + let back: FlowSuggestion = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, s); + } + + #[test] + fn flow_suggestion_defaults_optional_fields() { + // A minimal pitch (no trigger/steps/connections/slugs/status/run) must + // deserialize with safe defaults. + let json = serde_json::json!({ + "id": "sug_min", + "title": "Daily digest", + "one_liner": "Summarize your unread mail each morning.", + "rationale": "You check mail first thing.", + "build_prompt": "Build a scheduled digest…", + "created_at": "2026-07-05T00:00:00Z", + }); + let s: FlowSuggestion = serde_json::from_value(json).expect("deserialize"); + assert!(s.trigger_hint.is_none()); + assert!(s.steps_outline.is_empty()); + assert!(s.suggested_connections.is_empty()); + assert!(s.suggested_slugs.is_empty()); + assert_eq!(s.confidence, 0.0); + assert_eq!(s.status, SuggestionStatus::New); + assert!(s.source_run_id.is_none()); + } + diff --git a/src/openhuman/integrations/tools/stock_prices.rs b/src/openhuman/integrations/tools/stock_prices.rs index 2457099b6c..aa64be189e 100644 --- a/src/openhuman/integrations/tools/stock_prices.rs +++ b/src/openhuman/integrations/tools/stock_prices.rs @@ -13,7 +13,7 @@ //! Pricing is metered by the backend; the response includes `costUsd` per call. use super::IntegrationClient; -use crate::openhuman::tools::traits::{Tool, ToolResult}; +use crate::openhuman::tools::traits::{Tool, ToolExposure, ToolResult}; use async_trait::async_trait; use serde::Deserialize; use serde_json::json; @@ -146,6 +146,14 @@ impl StockQuoteTool { #[async_trait] impl Tool for StockQuoteTool { + /// Deferred: market data is a niche capability on a general assistant, and + /// these five schemas cost ~720 tokens on every turn of every agent that + /// carries them. The names are self-describing, so `tool_search("stock + /// price")` finds them on the turns that need them. + fn exposure(&self) -> ToolExposure { + ToolExposure::Deferred + } + fn name(&self) -> &str { "stock_quote" } @@ -221,6 +229,14 @@ impl StockExchangeRateTool { #[async_trait] impl Tool for StockExchangeRateTool { + /// Deferred: market data is a niche capability on a general assistant, and + /// these five schemas cost ~720 tokens on every turn of every agent that + /// carries them. The names are self-describing, so `tool_search("stock + /// price")` finds them on the turns that need them. + fn exposure(&self) -> ToolExposure { + ToolExposure::Deferred + } + fn name(&self) -> &str { "stock_exchange_rate" } @@ -309,6 +325,14 @@ impl StockOptionsTool { #[async_trait] impl Tool for StockOptionsTool { + /// Deferred: market data is a niche capability on a general assistant, and + /// these five schemas cost ~720 tokens on every turn of every agent that + /// carries them. The names are self-describing, so `tool_search("stock + /// price")` finds them on the turns that need them. + fn exposure(&self) -> ToolExposure { + ToolExposure::Deferred + } + fn name(&self) -> &str { "stock_options" } @@ -402,6 +426,14 @@ impl StockCryptoSeriesTool { #[async_trait] impl Tool for StockCryptoSeriesTool { + /// Deferred: market data is a niche capability on a general assistant, and + /// these five schemas cost ~720 tokens on every turn of every agent that + /// carries them. The names are self-describing, so `tool_search("stock + /// price")` finds them on the turns that need them. + fn exposure(&self) -> ToolExposure { + ToolExposure::Deferred + } + fn name(&self) -> &str { "stock_crypto_series" } @@ -497,6 +529,14 @@ impl StockCommodityTool { #[async_trait] impl Tool for StockCommodityTool { + /// Deferred: market data is a niche capability on a general assistant, and + /// these five schemas cost ~720 tokens on every turn of every agent that + /// carries them. The names are self-describing, so `tool_search("stock + /// price")` finds them on the turns that need them. + fn exposure(&self) -> ToolExposure { + ToolExposure::Deferred + } + fn name(&self) -> &str { "stock_commodity" } diff --git a/src/openhuman/memory/tools.rs b/src/openhuman/memory/tools.rs index b193209c88..b75cc4c60a 100644 --- a/src/openhuman/memory/tools.rs +++ b/src/openhuman/memory/tools.rs @@ -1,3 +1,4 @@ +mod collapsed; mod doctor; // `pub(crate)` (not `mod`): the tinyflows `memory` node's `OpenHumanMemory` // adapter (`crate::openhuman::flows::tinyflows::memory_adapter`) reaches @@ -22,6 +23,7 @@ pub mod search; pub mod tool_memory; pub use crate::openhuman::memory::query::*; +pub use collapsed::{MemoryTool, MEMORY_TOOL_NAME}; pub use doctor::MemoryDoctorTool; pub use flavour::MemoryFlavourTool; pub use forget::MemoryForgetTool; diff --git a/src/openhuman/memory/tools/collapsed.rs b/src/openhuman/memory/tools/collapsed.rs new file mode 100644 index 0000000000..526968d95d --- /dev/null +++ b/src/openhuman/memory/tools/collapsed.rs @@ -0,0 +1,244 @@ +//! `memory` — the memory surface as one action-dispatched tool. +//! +//! Replaces eleven advertised schemas (`memory_store`, `memory_recall`, +//! `memory_forget`, `memory_doctor`, `memory_flavour`, `memory_vector_search`, +//! `memory_chunk_context`, `memory_hybrid_search`, `memory_store_raw_search`, +//! `memory_store_raw_chunks`, `memory_store_kinds`) with one. Between them they +//! were 7,879 bytes on every request, and three of the eleven are variations on +//! "search this index with a query and a limit". +//! +//! Hermes' whole memory surface is a single `memory` tool for the same reason. +//! +//! # `memory_tree` is deliberately NOT folded in +//! +//! It is already a collapsed tool: it dispatches eight operations on a `mode` +//! field over the ingested email/chat/document tree, which is a different +//! subsystem with a different storage model. Folding it in would produce +//! two-level dispatch — `action: "tree"` plus `mode: "drill_down"` — which is +//! harder for a model to get right than two tools, and would put its 3 KB of +//! schema behind an action most turns never take. Two tools that each dispatch +//! once beat one tool that dispatches twice. +//! +//! # Permissions +//! +//! `permission_level_with_args` resolves the member's own level from the +//! action; the argument-free `permission_level` reports the strictest any +//! member requires, so an argument-less caller over-restricts rather than +//! under-. See `tools::implementations::meta::collapse`. +//! +//! **Pre-existing, and left alone:** this family declares one level between +//! them. Neither `memory_store` nor `memory_forget` overrides +//! `permission_level`, so a write and a delete both inherit the `ReadOnly` +//! default; both gate internally through their own `SecurityPolicy` + +//! `ToolOperation` check, so they are not ungated, but what they *declare* to +//! the approval gate is wrong. Collapsing reproduces that exactly and does not +//! correct it — raising them would change which turns get parked for approval, +//! which is a product decision rather than a token optimisation. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; + +use super::doctor::MemoryDoctorTool; +use super::flavour::MemoryFlavourTool; +use super::forget::MemoryForgetTool; +use super::raw_store::{MemoryStoreKindsTool, MemoryStoreRawChunksTool, MemoryStoreRawSearchTool}; +use super::recall::MemoryRecallTool; +use super::search::{MemoryChunkContextTool, MemoryHybridSearchTool, MemoryVectorSearchTool}; +use super::store::MemoryStoreTool; +use crate::openhuman::config::Config; +use crate::openhuman::security::policy::SecurityPolicy; +use crate::openhuman::tools::implementations::meta::collapse::{ + any_external_effect, args_without_action, merge_action_schemas, resolve, strictest_permission, + unknown_action_message, CollapsedAction, +}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; + +#[cfg(test)] +use crate::openhuman::tools::traits::ToolExposure; + +/// The advertised name. +pub const MEMORY_TOOL_NAME: &str = "memory"; + +pub struct MemoryTool { + store: MemoryStoreTool, + recall: MemoryRecallTool, + forget: MemoryForgetTool, + doctor: MemoryDoctorTool, + flavour: MemoryFlavourTool, + hybrid_search: MemoryHybridSearchTool, + vector_search: MemoryVectorSearchTool, + chunk_context: MemoryChunkContextTool, + raw_search: MemoryStoreRawSearchTool, + raw_chunks: MemoryStoreRawChunksTool, + kinds: MemoryStoreKindsTool, +} + +impl MemoryTool { + pub fn new(config: Arc, security: Arc) -> Self { + Self { + store: MemoryStoreTool::new(Arc::clone(&security)), + recall: MemoryRecallTool::new(), + forget: MemoryForgetTool::new(security), + doctor: MemoryDoctorTool::new(Arc::clone(&config)), + flavour: MemoryFlavourTool::new(config), + hybrid_search: MemoryHybridSearchTool, + vector_search: MemoryVectorSearchTool, + chunk_context: MemoryChunkContextTool, + raw_search: MemoryStoreRawSearchTool, + raw_chunks: MemoryStoreRawChunksTool, + kinds: MemoryStoreKindsTool, + } + } + + /// The action table, in the order it is advertised. + /// + /// Ordered by how often a turn needs it — `recall` and `store` first — so + /// the enum reads as a recommendation as well as a list. + /// + /// **Filtered by memory capability.** The eleven members span five of them + /// (`Core`, `Recall`, `Tree`, `Entities`, `Maintenance`), and the registry + /// drops a tool whose capability the active memory driver does not serve — + /// on the stated principle that absence beats a registered tool that + /// fails. Collapsing would have quietly broken that: one tool cannot be + /// dropped for one capability, so an unavailable action would sit in the + /// enum inviting a call that always errors. Filtering here keeps the + /// original behaviour, one action at a time. + fn actions(&self) -> Vec> { + self.all_actions() + .into_iter() + .filter(|entry| { + crate::core::all::capability_allowed(crate::openhuman::tools::ops::tool_capability( + entry.tool.name(), + )) + }) + .collect() + } + + /// Every action this tool can serve, before capability filtering. + fn all_actions(&self) -> Vec> { + vec![ + CollapsedAction { + action: "recall", + tool: &self.recall, + }, + CollapsedAction { + action: "store", + tool: &self.store, + }, + CollapsedAction { + action: "forget", + tool: &self.forget, + }, + CollapsedAction { + action: "hybrid_search", + tool: &self.hybrid_search, + }, + CollapsedAction { + action: "vector_search", + tool: &self.vector_search, + }, + CollapsedAction { + action: "chunk_context", + tool: &self.chunk_context, + }, + CollapsedAction { + action: "raw_search", + tool: &self.raw_search, + }, + CollapsedAction { + action: "raw_chunks", + tool: &self.raw_chunks, + }, + CollapsedAction { + action: "kinds", + tool: &self.kinds, + }, + CollapsedAction { + action: "flavour", + tool: &self.flavour, + }, + CollapsedAction { + action: "doctor", + tool: &self.doctor, + }, + ] + } +} + +#[async_trait] +impl Tool for MemoryTool { + fn name(&self) -> &str { + MEMORY_TOOL_NAME + } + + fn description(&self) -> &str { + "Read and write the user's long-term memory. `action`: `recall` \ + (retrieve memories for a query — start here), `store` (save a durable \ + fact), `forget` (delete one), `hybrid_search` (keyword + semantic over \ + stored chunks), `vector_search` (semantic only), `chunk_context` \ + (surrounding text for a chunk you already have), `raw_search` / \ + `raw_chunks` / `kinds` (the raw ingest store and what source kinds it \ + holds), `flavour` (the compiled persona profile: communication style, \ + stack, workflow, directives), `doctor` (diagnose an empty or stalled \ + memory pipeline). For ingested email, chat and documents use the \ + separate `memory_tree` tool instead." + } + + fn parameters_schema(&self) -> Value { + merge_action_schemas(&self.actions()) + } + + fn permission_level(&self) -> PermissionLevel { + strictest_permission(&self.actions()) + } + + fn permission_level_with_args(&self, args: &Value) -> PermissionLevel { + // `forget` is destructive and `recall` is read-only; reporting one + // level for both would either gate every read or let a delete through + // on a read's clearance. Unknown/missing actions take the strictest. + let actions = self.actions(); + args.get("action") + .and_then(Value::as_str) + .and_then(|action| resolve(&actions, action)) + .map(|entry| entry.tool.permission_level_with_args(args)) + .unwrap_or_else(|| strictest_permission(&actions)) + } + + fn external_effect(&self) -> bool { + any_external_effect(&self.actions()) + } + + fn supports_markdown(&self) -> bool { + true + } + + async fn execute(&self, args: Value) -> anyhow::Result { + self.execute_with_options(args, ToolCallOptions::default()) + .await + } + + async fn execute_with_options( + &self, + args: Value, + options: ToolCallOptions, + ) -> anyhow::Result { + let actions = self.actions(); + let requested = args.get("action").and_then(Value::as_str); + let Some(entry) = requested.and_then(|action| resolve(&actions, action)) else { + return Ok(ToolResult::error(unknown_action_message( + &actions, requested, + ))); + }; + tracing::debug!(action = %entry.action, "[tool][memory] dispatch"); + entry + .tool + .execute_with_options(args_without_action(&args), options) + .await + } +} + +#[cfg(test)] +#[path = "collapsed_tests.rs"] +mod tests; diff --git a/src/openhuman/memory/tools/collapsed_tests.rs b/src/openhuman/memory/tools/collapsed_tests.rs new file mode 100644 index 0000000000..f0817967dd --- /dev/null +++ b/src/openhuman/memory/tools/collapsed_tests.rs @@ -0,0 +1,107 @@ +use super::*; + +fn tool() -> MemoryTool { + MemoryTool::new( + Arc::new(Config::default()), + Arc::new(SecurityPolicy::default()), + ) +} + +#[test] +fn every_member_is_hidden_so_the_collapse_actually_saves_something() { + // `all_actions`, not `actions`: capability filtering could hide a + // still-advertised member from this check in some environments, and + // the property being pinned holds regardless of capabilities. + for entry in tool().all_actions() { + assert_eq!( + entry.tool.exposure(), + ToolExposure::Hidden, + "`{}` is still advertised alongside the collapsed `memory` tool", + entry.tool.name() + ); + } +} + +#[test] +fn the_schema_advertises_every_action() { + let schema = tool().parameters_schema(); + let listed = schema["properties"]["action"]["enum"] + .as_array() + .expect("enum") + .len(); + assert_eq!(listed, 11); +} + +#[test] +fn each_action_resolves_to_exactly_its_members_level() { + // The real contract, and the one that keeps collapsing honest: whatever + // a member declares, the collapsed tool reports for that action. + // + // Note this family currently declares one level between them (see the + // module docs): `memory_store` and `memory_forget` never override + // `permission_level`, so they inherit the `ReadOnly` default. That is + // pre-existing and deliberately not changed here — raising them is a + // behaviour change to the approval gate, not a token optimisation. An + // inequality assertion would therefore be asserting a bug. + let tool = tool(); + for entry in tool.actions() { + let args = serde_json::json!({"action": entry.action}); + assert_eq!( + tool.permission_level_with_args(&args), + entry.tool.permission_level_with_args(&args), + "action `{}` must report what `{}` reports", + entry.action, + entry.tool.name() + ); + } +} + +#[test] +fn collapsing_never_lowers_the_argument_free_level() { + // The safety property that does not depend on what the members happen + // to declare today: a caller that cannot pass arguments is never told + // a level below any member's. + let tool = tool(); + let floor = tool.permission_level(); + for entry in tool.actions() { + assert!( + entry.tool.permission_level() <= floor, + "`{}` requires more than the collapsed tool advertises", + entry.tool.name() + ); + } +} + +#[test] +fn an_unknown_action_falls_back_to_the_strictest_level() { + let tool = tool(); + assert_eq!( + tool.permission_level_with_args(&serde_json::json!({"action": "nope"})), + tool.permission_level() + ); +} + +#[tokio::test] +async fn an_unknown_action_is_an_error_result_naming_the_valid_ones() { + let result = tool() + .execute(serde_json::json!({"action": "recal"})) + .await + .expect("dispatch does not fail the call"); + assert!(result.is_error); + let text = format!("{result:?}"); + assert!(text.contains("recal")); + assert!(text.contains("recall|store|forget")); +} + +#[test] +fn the_memory_tree_tool_is_not_a_member() { + // Pinning the decision in the module docs: `memory_tree` dispatches on + // its own `mode`, and folding it in would make this two-level. + assert!( + !tool() + .all_actions() + .iter() + .any(|e| e.tool.name() == "memory_tree"), + "memory_tree stays a separate tool" + ); +} diff --git a/src/openhuman/memory/tools/forget.rs b/src/openhuman/memory/tools/forget.rs index e66bcdb05e..0212d37699 100644 --- a/src/openhuman/memory/tools/forget.rs +++ b/src/openhuman/memory/tools/forget.rs @@ -2,7 +2,7 @@ use crate::openhuman::memory::api::provider::MemoryCore; use crate::openhuman::memory::ops::guard::active_memory_guard; use crate::openhuman::security::policy::ToolOperation; use crate::openhuman::security::SecurityPolicy; -use crate::openhuman::tools::traits::{Tool, ToolResult}; +use crate::openhuman::tools::traits::{Tool, ToolExposure, ToolResult}; use async_trait::async_trait; use serde_json::json; use std::sync::Arc; @@ -22,6 +22,14 @@ impl MemoryForgetTool { #[async_trait] impl Tool for MemoryForgetTool { + /// Superseded by the `memory` tool, which dispatches every memory + /// operation on one `action` field. Kept registered and dispatchable so a + /// replayed transcript or a saved skill naming `memory_*` keeps working; + /// hidden from the wire so eleven schemas do not ship where one does. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "memory_forget" } diff --git a/src/openhuman/memory/tools/raw_store/kinds.rs b/src/openhuman/memory/tools/raw_store/kinds.rs index 91f4cdd0c3..46dc351b38 100644 --- a/src/openhuman/memory/tools/raw_store/kinds.rs +++ b/src/openhuman/memory/tools/raw_store/kinds.rs @@ -11,12 +11,20 @@ use serde_json::{json, Value}; use crate::openhuman::memory::api::provider::MemoryProvider; use crate::openhuman::memory::ops::guard::active_memory_guard; -use crate::openhuman::tools::traits::{Tool, ToolResult}; +use crate::openhuman::tools::traits::{Tool, ToolExposure, ToolResult}; pub struct MemoryStoreKindsTool; #[async_trait] impl Tool for MemoryStoreKindsTool { + /// Superseded by the `memory` tool, which dispatches every memory + /// operation on one `action` field. Kept registered and dispatchable so a + /// replayed transcript or a saved skill naming `memory_*` keeps working; + /// hidden from the wire so eleven schemas do not ship where one does. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "memory_store_kinds" } diff --git a/src/openhuman/memory/tools/raw_store/raw_chunks.rs b/src/openhuman/memory/tools/raw_store/raw_chunks.rs index 7c91e30748..98a7ef95d5 100644 --- a/src/openhuman/memory/tools/raw_store/raw_chunks.rs +++ b/src/openhuman/memory/tools/raw_store/raw_chunks.rs @@ -11,7 +11,7 @@ use serde_json::json; use crate::openhuman::memory::api::chunks::SourceKind; use crate::openhuman::memory::api::provider::{ChunkQuery, MemoryProvider}; use crate::openhuman::memory::ops::guard::active_memory_guard; -use crate::openhuman::tools::traits::{Tool, ToolResult}; +use crate::openhuman::tools::traits::{Tool, ToolExposure, ToolResult}; pub struct MemoryStoreRawChunksTool; @@ -35,6 +35,14 @@ struct Args { #[async_trait] impl Tool for MemoryStoreRawChunksTool { + /// Superseded by the `memory` tool, which dispatches every memory + /// operation on one `action` field. Kept registered and dispatchable so a + /// replayed transcript or a saved skill naming `memory_*` keeps working; + /// hidden from the wire so eleven schemas do not ship where one does. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "memory_store_raw_chunks" } diff --git a/src/openhuman/memory/tools/raw_store/raw_search.rs b/src/openhuman/memory/tools/raw_store/raw_search.rs index 7b050694d9..36ce91296d 100644 --- a/src/openhuman/memory/tools/raw_store/raw_search.rs +++ b/src/openhuman/memory/tools/raw_store/raw_search.rs @@ -12,7 +12,7 @@ use serde_json::json; use crate::openhuman::memory::api::provider::MemoryProvider; use crate::openhuman::memory::ops::guard::active_memory_guard; -use crate::openhuman::tools::traits::{Tool, ToolResult}; +use crate::openhuman::tools::traits::{Tool, ToolExposure, ToolResult}; pub struct MemoryStoreRawSearchTool; @@ -31,6 +31,14 @@ fn default_limit() -> usize { #[async_trait] impl Tool for MemoryStoreRawSearchTool { + /// Superseded by the `memory` tool, which dispatches every memory + /// operation on one `action` field. Kept registered and dispatchable so a + /// replayed transcript or a saved skill naming `memory_*` keeps working; + /// hidden from the wire so eleven schemas do not ship where one does. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "memory_store_raw_search" } diff --git a/src/openhuman/memory/tools/recall.rs b/src/openhuman/memory/tools/recall.rs index 9f3174bad8..1f54e20be6 100644 --- a/src/openhuman/memory/tools/recall.rs +++ b/src/openhuman/memory/tools/recall.rs @@ -1,7 +1,7 @@ use crate::openhuman::agent::tinyagents::host::agent_memory::DEFAULT_AGENT_MEMORY_NAMESPACE; use crate::openhuman::memory::api::provider::MemoryRecall; use crate::openhuman::memory::ops::guard::active_memory_guard; -use crate::openhuman::tools::traits::{Tool, ToolResult}; +use crate::openhuman::tools::traits::{Tool, ToolExposure, ToolResult}; use async_trait::async_trait; use serde_json::json; use std::fmt::Write; @@ -28,6 +28,14 @@ impl Default for MemoryRecallTool { #[async_trait] impl Tool for MemoryRecallTool { + /// Superseded by the `memory` tool, which dispatches every memory + /// operation on one `action` field. Kept registered and dispatchable so a + /// replayed transcript or a saved skill naming `memory_*` keeps working; + /// hidden from the wire so eleven schemas do not ship where one does. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "memory_recall" } diff --git a/src/openhuman/memory/tools/search/chunk_context.rs b/src/openhuman/memory/tools/search/chunk_context.rs index 93fdf1e083..d211b097d0 100644 --- a/src/openhuman/memory/tools/search/chunk_context.rs +++ b/src/openhuman/memory/tools/search/chunk_context.rs @@ -11,7 +11,7 @@ use std::fmt::Write; use crate::openhuman::memory::api::provider::{ChunkQuery, MemoryProvider}; use crate::openhuman::memory::ops::guard::active_memory_guard; -use crate::openhuman::tools::traits::{Tool, ToolResult}; +use crate::openhuman::tools::traits::{Tool, ToolExposure, ToolResult}; pub struct MemoryChunkContextTool; @@ -28,6 +28,14 @@ fn default_window() -> usize { #[async_trait] impl Tool for MemoryChunkContextTool { + /// Superseded by the `memory` tool, which dispatches every memory + /// operation on one `action` field. Kept registered and dispatchable so a + /// replayed transcript or a saved skill naming `memory_*` keeps working; + /// hidden from the wire so eleven schemas do not ship where one does. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "memory_chunk_context" } diff --git a/src/openhuman/memory/tools/store.rs b/src/openhuman/memory/tools/store.rs index 85e6ca150b..3d2970fd08 100644 --- a/src/openhuman/memory/tools/store.rs +++ b/src/openhuman/memory/tools/store.rs @@ -5,7 +5,7 @@ use crate::openhuman::memory::ops::guard::active_memory_guard; use crate::openhuman::memory::safety; use crate::openhuman::security::policy::ToolOperation; use crate::openhuman::security::SecurityPolicy; -use crate::openhuman::tools::traits::{Tool, ToolResult}; +use crate::openhuman::tools::traits::{Tool, ToolExposure, ToolResult}; use async_trait::async_trait; use serde_json::json; use sha2::{Digest, Sha256}; @@ -94,6 +94,14 @@ fn derive_key(content: &str) -> String { #[async_trait] impl Tool for MemoryStoreTool { + /// Superseded by the `memory` tool, which dispatches every memory + /// operation on one `action` field. Kept registered and dispatchable so a + /// replayed transcript or a saved skill naming `memory_*` keeps working; + /// hidden from the wire so eleven schemas do not ship where one does. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "memory_store" } diff --git a/src/openhuman/skills/bundled/mod.rs b/src/openhuman/skills/bundled/mod.rs new file mode 100644 index 0000000000..7503bc0fd6 --- /dev/null +++ b/src/openhuman/skills/bundled/mod.rs @@ -0,0 +1,327 @@ +//! Skills that ship **inside the binary**. +//! +//! A bundled skill is an ordinary SKILL.md bundle — frontmatter, a body, and +//! `references/` files — whose bytes are `include_str!`'d at compile time from +//! a directory in this repository. At boot it is written into the workspace +//! under [`builtin_root`] and from then on it is discovered, described, read +//! and run by exactly the same code paths as a skill the user installed. There +//! is no second reader, no virtual filesystem, and no `location: None` case for +//! downstream code to handle. +//! +//! # Why materialise instead of serving from memory +//! +//! Every consumer downstream of discovery resolves a real path: +//! `read_workflow_resource` canonicalises against the bundle root and refuses +//! symlinks, `run_skill` hands the worker a directory, and the UI shows the +//! user where the bundle lives. Serving embedded bytes instead would mean an +//! `Option` branch in each of those, and the security properties of +//! the resource reader — traversal and symlink rejection — would have to be +//! restated for the in-memory case rather than inherited. Writing the files out +//! once per version is cheaper than that, and it also makes a bundled skill +//! something the user can read on disk. +//! +//! # What this is NOT +//! +//! It is not an extension point. The table below is a `const` compiled into the +//! binary, exactly like [`crate::openhuman::modules::registry`], and for the +//! same reason: a table that config or RPC could add entries to would let a +//! remote party place instructions in front of the model. A skill the user +//! wants comes from `skill_registry_install`, which has its own review path. +//! +//! # Precedence +//! +//! Builtin is the **lowest** scope ([`WorkflowScope::Builtin`]). A user or +//! project skill with the same name shadows it, so shipping a bundle can never +//! take a name away from someone who already used it. + +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +/// One file inside a bundled skill, relative to the bundle directory. +#[derive(Debug, Clone, Copy)] +pub struct BundledFile { + /// Slash-separated path relative to the bundle root (`WORKFLOW.md`, + /// `references/expressions.md`). Validated by + /// [`BundledSkill::validate`] — see the test module. + pub path: &'static str, + pub contents: &'static str, +} + +/// A skill compiled into the binary. +#[derive(Debug, Clone, Copy)] +pub struct BundledSkill { + /// The on-disk directory name, and the id the model names in `run_skill` / + /// `describe_workflow`. + pub dir_name: &'static str, + pub files: &'static [BundledFile], +} + +impl BundledSkill { + /// A digest over the bundle's full contents, used to decide whether the + /// materialised copy on disk is current. + /// + /// Both the path and the contents of every file are hashed, each + /// length-prefixed, so that renaming a file or moving a byte between two + /// files changes the digest. Without the length prefix the concatenation + /// `("ab", "c")` and `("a", "bc")` would hash identically. + pub fn digest(&self) -> String { + let mut hasher = Sha256::new(); + for file in self.files { + hasher.update((file.path.len() as u64).to_le_bytes()); + hasher.update(file.path.as_bytes()); + hasher.update((file.contents.len() as u64).to_le_bytes()); + hasher.update(file.contents.as_bytes()); + } + format!("{:x}", hasher.finalize()) + } + + /// Reject a bundle this host will not write out. + /// + /// These are compile-time constants from this repository, so none of this + /// can fire in a shipped build — which is exactly why it is checked by a + /// test rather than trusted. The cost of being wrong is writing outside the + /// workspace, and "our own constants are fine" is the assumption that makes + /// that mistake survivable for one refactor too long. + pub fn validate(&self) -> Result<(), String> { + if self.dir_name.is_empty() + || self.dir_name.starts_with('.') + || self.dir_name.contains('/') + || self.dir_name.contains('\\') + { + return Err(format!( + "invalid bundled skill dir_name `{}`", + self.dir_name + )); + } + if self.files.is_empty() { + return Err(format!("bundled skill `{}` has no files", self.dir_name)); + } + let has_manifest = self.files.iter().any(|f| { + f.path == super::ops_types::WORKFLOW_MD || f.path == super::ops_types::SKILL_MD + }); + if !has_manifest { + return Err(format!( + "bundled skill `{}` has no WORKFLOW.md or SKILL.md; discovery would skip it", + self.dir_name + )); + } + for file in self.files { + validate_relative_path(self.dir_name, file.path)?; + } + Ok(()) + } +} + +fn validate_relative_path(dir_name: &str, path: &str) -> Result<(), String> { + if path.is_empty() { + return Err(format!("bundled skill `{dir_name}` has an empty file path")); + } + if path.starts_with('/') || path.starts_with('\\') || path.contains(':') { + return Err(format!( + "bundled skill `{dir_name}` file `{path}` is not workspace-relative" + )); + } + for component in path.split('/') { + if component.is_empty() || component == "." || component == ".." { + return Err(format!( + "bundled skill `{dir_name}` file `{path}` has a traversal component" + )); + } + if component.starts_with('.') { + return Err(format!( + "bundled skill `{dir_name}` file `{path}` has a dotfile component; \ + discovery skips those" + )); + } + } + Ok(()) +} + +/// The compiled-in table. +/// +/// Gated per entry, not as a whole: `flow-authoring` teaches the flows node +/// vocabulary, and in a build without the `flows` feature there is nothing for +/// it to author. Shipping it anyway would put a manual for absent tools in +/// front of the model — the same failure the `Off` tool mode exists to avoid. +pub const BUNDLED: &[BundledSkill] = &[ + #[cfg(feature = "flows")] + crate::openhuman::flows::skills::FLOW_AUTHORING, +]; + +/// Where materialised bundles live. +/// +/// Its own root rather than a subdirectory of `.openhuman/skills/`, because the +/// contents are ours to overwrite: [`install`] deletes and rewrites a bundle +/// whose digest moved, and doing that inside a directory the user also writes +/// to would eventually delete something of theirs. +pub fn builtin_root(workspace_dir: &Path) -> PathBuf { + workspace_dir.join(".openhuman").join("builtin-skills") +} + +/// Name of the digest sidecar written beside each materialised bundle. +/// +/// A dotfile so `scan_root` skips it (it skips `.`-prefixed entries) and it is +/// never mistaken for a skill resource. +const DIGEST_FILE: &str = ".digest"; + +/// What [`install`] did, for the boot log. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct InstallReport { + pub written: Vec, + pub unchanged: Vec, + pub failed: Vec<(String, String)>, +} + +/// Materialise every bundled skill into `workspace_dir`, skipping those whose +/// on-disk digest already matches. +/// +/// Never returns `Err`: a workspace that cannot take a builtin skill should +/// boot without it rather than not boot. Failures land in +/// [`InstallReport::failed`] and are logged. +pub fn install(workspace_dir: &Path) -> InstallReport { + let root = builtin_root(workspace_dir); + let mut report = InstallReport::default(); + + for skill in BUNDLED { + let name = skill.dir_name.to_string(); + match install_one(&root, skill) { + Ok(true) => { + tracing::info!(skill = %name, "[skills][bundled] wrote builtin skill"); + report.written.push(name); + } + Ok(false) => { + tracing::debug!(skill = %name, "[skills][bundled] builtin skill already current"); + report.unchanged.push(name); + } + Err(err) => { + tracing::warn!( + skill = %name, + error = %err, + "[skills][bundled] failed to install builtin skill; it will be absent this run" + ); + report.failed.push((name, err)); + } + } + } + + tracing::debug!( + root = %root.display(), + written = report.written.len(), + unchanged = report.unchanged.len(), + failed = report.failed.len(), + "[skills][bundled] install:exit" + ); + report +} + +/// Boot entry point: install every bundled skill and discard the report. +/// +/// Separate from [`install`] only so it can have the same signature as the +/// skills-off stub, which cannot name [`InstallReport`] (that type lives inside +/// the gate). The one caller logs nothing extra — [`install`] already logs per +/// skill — so nothing is lost by dropping the report here. +pub fn install_bundled_skills(workspace_dir: &Path) { + let _ = install(workspace_dir); +} + +/// Whether `dir` contains exactly the bytes compiled for `skill`. +/// +/// Discovery must not trust the sidecar alone: a user who can modify the +/// materialised directory must not be able to turn arbitrary instructions into +/// a `Builtin` workflow. Check every compiled file and reject extra entries, +/// symlinks, and non-regular files. +pub fn is_current_materialization(dir: &Path, skill: &BundledSkill) -> bool { + let Ok(dir_meta) = std::fs::symlink_metadata(dir) else { + return false; + }; + if skill.validate().is_err() + || !dir_meta.is_dir() + || dir_meta.file_type().is_symlink() + { + return false; + } + let expected: std::collections::HashSet<&str> = skill.files.iter().map(|f| f.path).collect(); + for file in skill.files { + let path = dir.join(file.path); + let Ok(meta) = std::fs::symlink_metadata(&path) else { + return false; + }; + if !meta.is_file() || meta.file_type().is_symlink() { + return false; + } + let Ok(contents) = std::fs::read(&path) else { + return false; + }; + if contents != file.contents.as_bytes() { + return false; + } + } + let mut stack = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + let Ok(entries) = std::fs::read_dir(¤t) else { + return false; + }; + for entry in entries.flatten() { + let Ok(meta) = std::fs::symlink_metadata(entry.path()) else { + return false; + }; + let Ok(relative) = entry.path().strip_prefix(dir) else { + return false; + }; + let relative = relative.to_string_lossy().replace('\\', "/"); + if meta.file_type().is_symlink() || (!meta.is_dir() && !meta.is_file()) { + return false; + } + if meta.is_dir() { + stack.push(entry.path()); + } else if relative != DIGEST_FILE && !expected.contains(relative.as_str()) { + return false; + } + } + } + true +} + +/// Returns `Ok(true)` when the bundle was (re)written, `Ok(false)` when the +/// on-disk copy was already current. +fn install_one(root: &Path, skill: &BundledSkill) -> Result { + skill.validate()?; + + let dir = root.join(skill.dir_name); + let digest = skill.digest(); + let digest_path = dir.join(DIGEST_FILE); + + if std::fs::read_to_string(&digest_path).is_ok_and(|found| found.trim() == digest) { + return Ok(false); + } + + // Remove rather than overwrite: a previous version may have shipped a file + // this one does not, and leaving it behind would let a stale reference doc + // keep answering `read_workflow_resource` calls forever. + if dir.exists() { + std::fs::remove_dir_all(&dir) + .map_err(|e| format!("failed to clear {}: {e}", dir.display()))?; + } + + for file in skill.files { + let target = dir.join(file.path); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create {}: {e}", parent.display()))?; + } + std::fs::write(&target, file.contents) + .map_err(|e| format!("failed to write {}: {e}", target.display()))?; + } + + // Written last, so an interrupted install leaves no digest and the next + // boot rewrites the bundle rather than trusting a half-written one. + std::fs::write(&digest_path, &digest) + .map_err(|e| format!("failed to write {}: {e}", digest_path.display()))?; + + Ok(true) +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod mod_tests; diff --git a/src/openhuman/skills/bundled/mod_tests.rs b/src/openhuman/skills/bundled/mod_tests.rs new file mode 100644 index 0000000000..9c495a673f --- /dev/null +++ b/src/openhuman/skills/bundled/mod_tests.rs @@ -0,0 +1,301 @@ +//! Tests for the compiled-in skill table and its materialisation. + +use super::*; + +const A: BundledFile = BundledFile { + path: "WORKFLOW.md", + contents: "---\nname: sample\ndescription: a sample\n---\n\nbody\n", +}; +const B: BundledFile = BundledFile { + path: "references/detail.md", + contents: "detail\n", +}; + +const SAMPLE: BundledSkill = BundledSkill { + dir_name: "sample", + files: &[A, B], +}; + +#[test] +fn every_shipped_bundle_is_valid() { + // The one assertion that covers the real table. `validate` exists because + // a bad `dir_name` or a `..` in a file path would write outside the + // workspace; a shipped build never runs it, so this is where it runs. + for skill in BUNDLED { + skill + .validate() + .unwrap_or_else(|e| panic!("bundled skill `{}` is invalid: {e}", skill.dir_name)); + } +} + +#[test] +fn shipped_bundle_names_are_unique() { + // Two entries with one `dir_name` would write over each other, and which + // one won would depend on table order. + let mut seen = std::collections::HashSet::new(); + for skill in BUNDLED { + assert!( + seen.insert(skill.dir_name), + "two bundled skills both claim `{}`", + skill.dir_name + ); + } +} + +#[test] +fn the_digest_covers_paths_as_well_as_contents() { + // The length-prefix rule from `digest`'s docs: moving a byte between two + // files, or renaming a file, must change the digest. Without prefixing, + // both of these collide with SAMPLE. + let moved = BundledSkill { + dir_name: "sample", + files: &[ + BundledFile { + path: "WORKFLOW.md", + contents: A.contents, + }, + BundledFile { + path: "references/detail.md", + contents: "detai", + }, + ], + }; + let renamed = BundledSkill { + dir_name: "sample", + files: &[ + A, + BundledFile { + path: "references/other.md", + contents: B.contents, + }, + ], + }; + assert_ne!(SAMPLE.digest(), moved.digest()); + assert_ne!(SAMPLE.digest(), renamed.digest()); +} + +#[test] +fn a_traversal_path_is_rejected() { + // Checked against the free function rather than through `validate`, + // because `BundledSkill::files` is `&'static [_]` — a table compiled into + // the binary cannot be assembled from loop variables, which is itself part + // of why the table is safe. + for bad in [ + "../escape.md", + "refs/../../escape.md", + "/etc/passwd", + "refs//x.md", + "C:/windows/system32", + ".hidden.md", + "", + ] { + assert!( + validate_relative_path("sample", bad).is_err(), + "`{bad}` must be rejected as a bundled file path" + ); + } + for good in ["WORKFLOW.md", "references/expressions.md", "scripts/run.py"] { + assert!( + validate_relative_path("sample", good).is_ok(), + "`{good}` must be accepted" + ); + } +} + +#[test] +fn a_bad_dir_name_is_rejected() { + for bad in ["", ".hidden", "a/b", "a\\b"] { + let skill = BundledSkill { + dir_name: bad, + files: &[A], + }; + assert!( + skill.validate().is_err(), + "`{bad}` must be rejected as a dir_name" + ); + } +} + +#[test] +fn a_bundle_with_no_manifest_is_rejected() { + // Discovery only loads a directory holding WORKFLOW.md / SKILL.md / + // skill.json. A bundle without one would be written out and then silently + // never appear — the worst failure mode available, because nothing errors. + let skill = BundledSkill { + dir_name: "sample", + files: &[BundledFile { + path: "references/detail.md", + contents: "x", + }], + }; + assert!(skill.validate().is_err()); +} + +#[test] +fn install_writes_the_files_and_is_idempotent() { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = builtin_root(tmp.path()); + + assert!(install_one(&root, &SAMPLE).expect("first install")); + let body = std::fs::read_to_string(root.join("sample").join("WORKFLOW.md")).expect("body"); + assert_eq!(body, A.contents); + let detail = + std::fs::read_to_string(root.join("sample").join("references/detail.md")).expect("detail"); + assert_eq!(detail, B.contents); + + // Second call must not rewrite — the digest matches. + assert!(!install_one(&root, &SAMPLE).expect("second install")); +} + +#[test] +fn a_version_bump_removes_a_file_the_new_version_dropped() { + // The reason `install_one` deletes instead of overwriting. A stale + // reference doc left behind would keep answering `read_workflow_resource` + // after the skill stopped shipping it. + let tmp = tempfile::tempdir().expect("tempdir"); + let root = builtin_root(tmp.path()); + assert!(install_one(&root, &SAMPLE).expect("install v1")); + + let v2 = BundledSkill { + dir_name: "sample", + files: &[A], + }; + assert!(install_one(&root, &v2).expect("install v2")); + assert!( + !root.join("sample").join("references/detail.md").exists(), + "the dropped reference file must not survive the upgrade" + ); +} + +#[test] +fn an_interrupted_install_is_redone() { + // The digest is written last on purpose. Simulate the interruption by + // removing it: the next install must rewrite rather than trust the + // directory. + let tmp = tempfile::tempdir().expect("tempdir"); + let root = builtin_root(tmp.path()); + assert!(install_one(&root, &SAMPLE).expect("install")); + std::fs::remove_file(root.join("sample").join(".digest")).expect("remove digest"); + assert!( + install_one(&root, &SAMPLE).expect("reinstall"), + "a bundle with no digest must be rewritten" + ); +} + +#[test] +fn install_reports_rather_than_fails_the_boot() { + // `install` is called from workspace init. A workspace it cannot write + // must not stop the core from starting. + let tmp = tempfile::tempdir().expect("tempdir"); + let report = install(tmp.path()); + assert!( + report.failed.is_empty(), + "clean workspace must install: {report:?}" + ); + assert_eq!( + report.written.len() + report.unchanged.len(), + BUNDLED.len(), + "every bundled skill must be accounted for" + ); +} + +#[test] +fn a_materialised_bundle_is_discoverable_and_readable_end_to_end() { + // The test that proves the whole mechanism, because every other test here + // stops at the filesystem. A bundle can be written correctly and still be + // useless: discovery could skip the builtin root, the scope could be + // rejected, the frontmatter could fail to parse, or the resource reader + // could refuse a path under a root it does not recognise. This walks the + // real path the model walks — install, discover, read a page. + let tmp = tempfile::tempdir().expect("tempdir"); + let workspace = tmp.path(); + let report = install(workspace); + assert!(report.failed.is_empty(), "install failed: {report:?}"); + + for skill in BUNDLED { + let found = crate::openhuman::skills::discover_workflows(None, Some(workspace), false); + let entry = found + .iter() + .find(|w| w.dir_name == skill.dir_name) + .unwrap_or_else(|| { + panic!( + "`{}` was written but discovery did not find it; discovered: {:?}", + skill.dir_name, + found.iter().map(|w| &w.dir_name).collect::>() + ) + }); + assert_eq!( + entry.scope, + crate::openhuman::skills::ops_types::WorkflowScope::Builtin, + "a materialised bundle must carry the Builtin scope" + ); + assert!( + !entry.description.is_empty(), + "`{}` frontmatter did not parse — discovery found the directory but \ + not its metadata", + skill.dir_name + ); + + // `home_dir = None` above; the reader resolves through the real + // discovery pipeline including `dirs::home_dir()`, which is fine here: + // it can only ADD skills, and we look up ours by name. + for file in skill.files { + if file.path == super::super::ops_types::WORKFLOW_MD { + continue; + } + let body = crate::openhuman::skills::read_workflow_resource( + workspace, + skill.dir_name, + std::path::Path::new(file.path), + ) + .unwrap_or_else(|e| { + panic!( + "reading `{}` of `{}` failed: {e}", + file.path, skill.dir_name + ) + }); + assert_eq!( + body, file.contents, + "`{}` read back different bytes than it ships", + file.path + ); + } + } +} + +#[test] +fn a_user_skill_of_the_same_name_shadows_the_builtin() { + // The precedence promise from the module docs, and the one that makes + // shipping a bundle safe: adding a builtin can never take a name away from + // a workspace that already used it. + let Some(first) = BUNDLED.first() else { + return; + }; + let tmp = tempfile::tempdir().expect("tempdir"); + let workspace = tmp.path(); + install(workspace); + + // A legacy-scope skill (`/skills/`) is the lowest non-builtin + // scope, so if even that wins, every other scope does. + let user_dir = workspace.join("skills").join(first.dir_name); + std::fs::create_dir_all(&user_dir).expect("mkdir"); + std::fs::write( + user_dir.join("SKILL.md"), + format!( + "---\nname: {}\ndescription: the user's own version\n---\n\nmine\n", + first.dir_name + ), + ) + .expect("write"); + + let found = crate::openhuman::skills::discover_workflows(None, Some(workspace), false); + let entry = found + .iter() + .find(|w| w.dir_name == first.dir_name) + .expect("found"); + assert_eq!( + entry.description, "the user's own version", + "the user's skill must win the name; got scope {:?}", + entry.scope + ); +} diff --git a/src/openhuman/skills/mod.rs b/src/openhuman/skills/mod.rs index 37f87ca32a..b0d07cf011 100644 --- a/src/openhuman/skills/mod.rs +++ b/src/openhuman/skills/mod.rs @@ -47,6 +47,8 @@ pub mod catalog; pub mod runtime; pub mod webhooks; +#[cfg(feature = "skills")] +pub mod bundled; #[cfg(feature = "skills")] pub mod bus; #[cfg(feature = "skills")] @@ -68,6 +70,8 @@ pub mod run_log; #[cfg(feature = "skills")] pub mod schemas; #[cfg(feature = "skills")] +pub mod search; +#[cfg(feature = "skills")] pub mod tools; #[cfg(all(test, feature = "skills"))] diff --git a/src/openhuman/skills/ops.rs b/src/openhuman/skills/ops.rs index 28a7d09e07..3c0898e7e6 100644 --- a/src/openhuman/skills/ops.rs +++ b/src/openhuman/skills/ops.rs @@ -29,6 +29,7 @@ // Re-export everything that was previously public from this file so external // callers are unaffected. +pub use super::bundled::install_bundled_skills; pub use super::ops_create::{create_workflow, CreateWorkflowParams, WorkflowCreateInputDef}; pub use super::ops_discover::{ discover_automations, discover_workflows, discover_workflows_with_profile, init_workflows_dir, diff --git a/src/openhuman/skills/ops_create.rs b/src/openhuman/skills/ops_create.rs index ce9ebec955..cc3dfad328 100644 --- a/src/openhuman/skills/ops_create.rs +++ b/src/openhuman/skills/ops_create.rs @@ -152,7 +152,15 @@ fn legacy_workflow_dir( // Profile-local skills are placed by hand under // `/personalities//skills/`, never scaffolded through // the create path; treat them like Legacy here (no create target). - WorkflowScope::Legacy | WorkflowScope::Profile => return None, + // Builtin bundles come from a `const` table compiled into the + // binary; a create RPC that could write one would make that table + // remotely extensible, which is the whole thing it exists to prevent. + // Flow entries are rows in `flows.db`, not bundle directories — there + // is no path to resolve. Creating one is `save_workflow`'s job. + WorkflowScope::Builtin + | WorkflowScope::Legacy + | WorkflowScope::Profile + | WorkflowScope::Flow => return None, }; for root in roots { let canonical_root = match std::fs::canonicalize(&root) { @@ -217,7 +225,17 @@ pub(crate) fn create_workflow_inner( } workspace_dir.join(".openhuman").join("workflows") } - WorkflowScope::Legacy | WorkflowScope::Profile => { + WorkflowScope::Flow => { + // Named separately from the others because the fix differs: the + // caller does not want a different skill scope, they want a + // different tool. + return Err( + "'flow' is not a skill scope — a Flows automation is a saved graph, not a \ + SKILL.md bundle. Use `save_workflow` / `create_workflow` to author one." + .to_string(), + ); + } + WorkflowScope::Builtin | WorkflowScope::Legacy | WorkflowScope::Profile => { return Err( "cannot create skill in legacy or profile scope; choose 'user' or 'project'" .to_string(), diff --git a/src/openhuman/skills/ops_discover.rs b/src/openhuman/skills/ops_discover.rs index 29186531e0..e0f1b4f186 100644 --- a/src/openhuman/skills/ops_discover.rs +++ b/src/openhuman/skills/ops_discover.rs @@ -181,13 +181,9 @@ pub(crate) fn discover_workflows_inner( ) } -/// Discover only *automation* bundles — those under the `workflows/` roots — -/// for the Automations UI list (`openhuman.skills_list`). -/// -/// Capability skills (under the `skills/` / `.agents/skills/` / legacy -/// `/skills/` roots) are deliberately excluded so they don't show up -/// as task templates. They remain fully available to the agent harness and the -/// run/describe paths via [`discover_workflows`] / [`load_workflow_metadata`]. +/// Discover only automation bundles under the `workflows/` roots. +/// Capability skills are deliberately excluded; they remain available to the +/// agent harness and run/describe paths. /// /// Note: bundles authored *before* the skills→workflows rename live under the /// `skills/` roots and will therefore not appear in this automations-only view; @@ -229,6 +225,27 @@ fn discover_filtered( // a name wins, so we scan user first, then project, then legacy. let mut by_name: HashMap = HashMap::new(); + // Builtin skills (`/.openhuman/builtin-skills/`) are a skill + // root scanned FIRST and at the lowest precedence, so every other scope + // shadows them on a name collision. No trust marker is consulted: the + // directory is core-managed and its contents were written from constants + // compiled into this binary, which is a stronger provenance claim than the + // marker makes about a project directory. See `skills::bundled`. + if let Some(ws) = workspace_dir { + if kinds.contains(&RootKind::Skill) { + let root = crate::openhuman::skills::bundled::builtin_root(ws); + tracing::trace!( + root = %root.display(), + scope = ?WorkflowScope::Builtin, + "[workflows] discover:branch:builtin" + ); + absorb( + &mut by_name, + scan_bundled_root(&root, WorkflowScope::Builtin), + ); + } + } + if let Some(home) = home_dir { for (root, kind) in user_roots(home) { if kinds.contains(&kind) { @@ -305,6 +322,19 @@ fn discover_filtered( out } +fn scan_bundled_root(root: &Path, scope: WorkflowScope) -> Vec { + let mut out = Vec::new(); + for bundled in crate::openhuman::skills::bundled::BUNDLED { + let dir = root.join(bundled.dir_name); + if crate::openhuman::skills::bundled::is_current_materialization(&dir, bundled) { + if let Some(workflow) = load_skill_dir(&dir, bundled.dir_name, scope) { + out.push(workflow); + } + } + } + out +} + fn user_roots(home: &Path) -> Vec<(PathBuf, RootKind)> { // `workflows/` is the current layout (create writes here); the `skills/` // roots are still scanned for back-compat with installs created before the @@ -398,11 +428,21 @@ fn absorb(by_name: &mut HashMap, incoming: Vec) { fn precedence(scope: WorkflowScope) -> u8 { match scope { - WorkflowScope::Legacy => 0, - WorkflowScope::User => 1, - WorkflowScope::Project => 2, + // Builtin sits below everything, including Legacy: a bundle that ships + // with the binary must never shadow something the user installed or + // wrote. Adding a builtin skill is then a change that cannot take a + // name away from an existing workspace. + WorkflowScope::Builtin => 0, + WorkflowScope::Legacy => 1, + WorkflowScope::User => 2, + WorkflowScope::Project => 3, // Profile-local skills win against every global scope for their owner. - WorkflowScope::Profile => 3, + WorkflowScope::Profile => 4, + // Flows are never discovered by this scanner, so they never take part + // in a name collision resolved here. Ranked above everything so that + // if one ever reaches this function the answer is deterministic rather + // than accidental. + WorkflowScope::Flow => 5, } } @@ -718,11 +758,9 @@ fn resolve_workflow_for_resource( (None, None) => Err(format!("skill '{skill_id}' not found")), } } - #[cfg(test)] #[path = "ops_discover_include_skills_tests_tests.rs"] mod include_skills_tests; - #[cfg(test)] #[path = "ops_discover_profile_scope_tests_tests.rs"] mod profile_scope_tests; diff --git a/src/openhuman/skills/ops_types.rs b/src/openhuman/skills/ops_types.rs index 31d90c42bc..9ac1e150c7 100644 --- a/src/openhuman/skills/ops_types.rs +++ b/src/openhuman/skills/ops_types.rs @@ -43,6 +43,12 @@ pub const MAX_WORKFLOW_RESOURCE_BYTES: u64 = 128 * 1024; #[serde(rename_all = "lowercase")] #[derive(Default)] pub enum WorkflowScope { + /// A skill compiled into the binary and materialised under + /// `/.openhuman/builtin-skills/` at boot. LOWEST precedence: + /// a user or project skill of the same name shadows it, so shipping a + /// bundle can never take a name away from someone already using it. + /// See `skills::bundled`. + Builtin, /// Workflow shipped with the user's global config (`~/.openhuman/skills/...`). #[default] User, @@ -57,6 +63,22 @@ pub enum WorkflowScope { /// profile-local skill shadows a same-named global one for its owner. See /// `ops_discover::discover_workflows_with_profile`. Profile, + /// A saved **Flows automation** (a tinyflows graph), surfaced in the same + /// catalogue as SKILL.md bundles. + /// + /// **Not discovered from disk.** Every other scope is a directory the + /// skill scanner walked; this one is a row in `flows.db`, mapped into a + /// catalogue entry by `flows::catalogue`. It is a listing, not a bundle: + /// there is no `SKILL.md`, so `describe_workflow` and + /// `read_workflow_resource` have nothing to read and say so by name rather + /// than failing generically. + /// + /// It exists because a user asking "what can this thing already do for me" + /// does not distinguish the two, and neither should the catalogue. Before + /// this, the prompt carried ~200 bytes of caveat teaching the model that + /// the list it was reading deliberately omitted half the answer, and that + /// calling the obvious tool on the missing half "will error". + Flow, } /// Parsed frontmatter of a `SKILL.md` file. diff --git a/src/openhuman/skills/search.rs b/src/openhuman/skills/search.rs new file mode 100644 index 0000000000..2b85934520 --- /dev/null +++ b/src/openhuman/skills/search.rs @@ -0,0 +1,292 @@ +//! `skill_search` — find an installed skill without listing them all. +//! +//! # Why this exists +//! +//! Skills reach the model two ways today, and both scale badly with how many +//! are installed. The orchestrator prompt carries a `## Installed Skills` +//! catalogue — one line per skill, on every turn, forever — and `list_workflows` +//! serialises the **whole** [`Workflow`] struct for every skill, frontmatter +//! and resource list included. With a handful of skills neither matters. Now +//! that skills also ship inside the binary ([`super::bundled`]) and a catalogue +//! install is one tool call away, both are a bill that grows without anyone +//! deciding it should. +//! +//! Search is the third way: pay for the one skill that matches, and nothing for +//! the rest. It is the same bargain [`crate::openhuman::tools::implementations::meta::tool_search`] +//! makes for deferred tool schemas, over a different corpus. +//! +//! # What it returns, and what it does not +//! +//! A projection — id, name, a capped description, scope, tags — not the +//! `Workflow`. Returning the struct would reintroduce the cost the search +//! exists to avoid, and the body is one `describe_workflow` away for the one +//! skill the model actually picked. +//! +//! # The seam +//! +//! Ranking is [`crate::openhuman::util::bm25`], which names nothing from this +//! crate. The host-owned half is everything else in this file: which roots are +//! scanned, whether the workspace is trusted, which profile's private skills +//! are in scope, and the per-profile allowlist. That is the split to preserve +//! if this ever becomes a loadable module — a module can rank, but it cannot be +//! the thing that decides whose skills a caller may see. + +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use super::ops_discover::{discover_workflows_with_profile, is_workspace_trusted}; +use super::ops_types::{Workflow, WorkflowScope}; +use super::tools::{skill_allowed, SkillAllowlist}; +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{Tool, ToolResult}; +use crate::openhuman::util::bm25::Bm25Index; + +/// How many matches a search returns when the caller does not say. +const DEFAULT_LIMIT: usize = 5; +/// Ceiling on `limit`. A search that can return everything is `list_workflows` +/// with extra steps, and would spend exactly what this tool saves. +const MAX_LIMIT: usize = 20; +/// Cap on a returned description. +/// +/// Skill descriptions are third-party metadata. The same 240 the orchestrator +/// prompt's catalogue uses — see `render_installed_skills` — so a skill cannot +/// present one size in the catalogue and a different one here. +const MAX_DESCRIPTION: usize = 240; + +pub const SKILL_SEARCH_NAME: &str = "skill_search"; + +/// The text a skill is matched on. +/// +/// `dir_name` is included as well as `name` because they diverge — the id the +/// model must eventually type is `dir_name`, and a skill whose frontmatter +/// display name differs would otherwise be unfindable by the name it is +/// actually called by. +fn searchable_text(workflow: &Workflow) -> String { + format!( + "{} {} {} {}", + workflow.dir_name, + workflow.name, + workflow.description, + workflow.tags.join(" ") + ) +} + +fn id_of(workflow: &Workflow) -> &str { + if workflow.dir_name.is_empty() { + &workflow.name + } else { + &workflow.dir_name + } +} + +/// Rank `workflows` against `query`, best first, at most `limit` results. +/// +/// Split out from the tool so the ranking is testable without a workspace, a +/// config or a filesystem. +pub fn rank<'a>(workflows: &'a [Workflow], query: &str, limit: usize) -> Vec<&'a Workflow> { + let texts: Vec<(String, String)> = workflows + .iter() + .map(|w| (id_of(w).to_string(), searchable_text(w))) + .collect(); + let index = Bm25Index::build(texts.iter().map(|(id, text)| (id.as_str(), text.as_str()))); + index + .search(query, limit) + .into_iter() + .map(|i| &workflows[i]) + .collect() +} + +/// The compact per-result projection. +fn project(workflow: &Workflow) -> Value { + json!({ + "id": id_of(workflow), + "name": workflow.name, + // Sanitised for the same reason the prompt catalogue sanitises: this is + // author-controlled text about to be read by a model as if the host had + // said it. `sanitize_for_llm` strips control characters and instruction + // fences and caps the length. + "description": crate::openhuman::util::sanitize::sanitize_for_llm( + &workflow.description, + MAX_DESCRIPTION, + ), + "tags": workflow.tags, + "scope": workflow.scope, + }) +} + +/// Search installed skills by capability. +pub struct SkillSearchTool { + workspace_dir: PathBuf, + skill_allowlist: SkillAllowlist, + profile_skills_root: Option, + /// Kept whole so saved Flows automations can be listed alongside SKILL.md + /// bundles. Search has to see the same catalogue the prompt renders, or + /// "find me the thing that does X" answers from half the library. + config: Arc, +} + +impl SkillSearchTool { + pub fn new(config: Arc) -> Self { + Self { + workspace_dir: config.workspace_dir.clone(), + skill_allowlist: None, + profile_skills_root: None, + config, + } + } + + /// Scope results to a per-profile allowlist of `dir_name` slugs. + pub fn with_skill_allowlist(mut self, allowlist: SkillAllowlist) -> Self { + self.skill_allowlist = allowlist; + self + } + + /// Include the active profile's private skills. + pub fn with_profile_skills_root(mut self, root: Option) -> Self { + self.profile_skills_root = root; + self + } + + /// The visible corpus for this caller. + /// + /// Identical filtering to `list_workflows` — deliberately, and this is the + /// part that must not drift: a search that saw one skill more than the list + /// would be a way to discover a skill the profile was scoped away from. + fn visible(&self) -> Vec { + let trusted = is_workspace_trusted(&self.workspace_dir); + let mut workflows = discover_workflows_with_profile( + dirs::home_dir().as_deref(), + Some(&self.workspace_dir), + self.profile_skills_root.as_deref(), + trusted, + ); + if self.skill_allowlist.is_some() { + workflows.retain(|w| { + // Builtin and profile-local scopes bypass the allowlist — see + // `tools::is_builtin_skill`. Search must apply exactly the + // filter `list_workflows` applies; a search that saw one skill + // more than the list would be a way around the scoping. + w.scope == WorkflowScope::Builtin + || w.scope == WorkflowScope::Profile + || skill_allowed(&self.skill_allowlist, &w.dir_name) + }); + } + // Saved Flows automations, appended AFTER the allowlist filter. + // + // A profile's skill allowlist is a list of `dir_name` slugs for + // SKILL.md bundles; it has no opinion about flow ids, so running flows + // through it would filter every one of them out on any profile that + // sets an allowlist — silently, and looking exactly like "you have no + // automations". Flow visibility is the flow store's business. + #[cfg(feature = "flows")] + workflows.extend(crate::openhuman::flows::catalogue::flow_entries( + &self.config, + )); + workflows + } +} + +#[async_trait] +impl Tool for SkillSearchTool { + fn name(&self) -> &str { + SKILL_SEARCH_NAME + } + + fn description(&self) -> &str { + "Find an installed skill by what it does. Give a plain-language query \ + (\"turn a repo into a changelog\", \"post to discord\") and get back the \ + best-matching skills with their id and description. Prefer this over \ + `list_workflows` when you know the capability you want but not the \ + name — it returns only what matched. Then `describe_workflow` for the \ + details, or `run_skill` to run it. Searches only what is installed \ + locally; use `skill_registry_search` to find skills to install." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "What you want done, in plain language." + }, + "limit": { + "type": "integer", + "description": format!( + "Maximum results (default {DEFAULT_LIMIT}, max {MAX_LIMIT})." + ), + } + }, + "required": ["query"] + }) + } + + fn is_concurrency_safe(&self, _args: &Value) -> bool { + true + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let query = args + .get("query") + .and_then(Value::as_str) + .unwrap_or_default(); + if query.trim().is_empty() { + return Ok(ToolResult::error( + "skill_search needs a `query` describing what you want done.".to_string(), + )); + } + let limit = args + .get("limit") + .and_then(Value::as_u64) + .map(|n| (n as usize).clamp(1, MAX_LIMIT)) + .unwrap_or(DEFAULT_LIMIT); + + let workflows = self.visible(); + let matches = rank(&workflows, query, limit); + tracing::debug!( + installed = workflows.len(), + matched = matches.len(), + limit, + "[tool][skill_search] ranked" + ); + + Ok(ToolResult::success(serde_json::to_string(&render( + &matches, + workflows.len(), + ))?)) + } +} + +/// Build the tool's reply. +/// +/// Split out from `execute` so it is testable without a workspace: the tool +/// itself discovers through `dirs::home_dir()`, so any test that went through +/// `execute` would rank against whatever skills the developer running it +/// happens to have installed. That is the same non-hermetic trap the prompt +/// budget lane hit — a test whose corpus is the machine it runs on. +fn render(matches: &[&Workflow], installed: usize) -> Value { + if matches.is_empty() { + // An explicit miss, not an empty list. "No skill matched" and "you have + // no skills installed" call for different next moves, and a bare `[]` + // does not distinguish them. + return json!({ + "matched": 0, + "installed": installed, + "skills": [], + "hint": "No installed skill matched. Try `skill_registry_search` to \ + find one to install, or just do the task directly.", + }); + } + json!({ + "matched": matches.len(), + "installed": installed, + "skills": matches.iter().map(|w| project(w)).collect::>(), + }) +} + +#[cfg(test)] +#[path = "search_tests.rs"] +mod search_tests; diff --git a/src/openhuman/skills/search_tests.rs b/src/openhuman/skills/search_tests.rs new file mode 100644 index 0000000000..ed376fe4b0 --- /dev/null +++ b/src/openhuman/skills/search_tests.rs @@ -0,0 +1,182 @@ +//! Tests for `skill_search`. + +use super::*; + +fn skill(dir: &str, name: &str, description: &str, tags: &[&str]) -> Workflow { + Workflow { + dir_name: dir.to_string(), + name: name.to_string(), + description: description.to_string(), + tags: tags.iter().map(|t| t.to_string()).collect(), + ..Default::default() + } +} + +fn corpus() -> Vec { + vec![ + skill( + "flow-authoring", + "flow-authoring", + "The tinyflows authoring reference: expression syntax, node configuration, dry runs.", + &["flows", "workflows", "reference"], + ), + skill( + "changelog", + "Release changelog", + "Turn a git commit range into a written changelog for a release.", + &["git", "release"], + ), + skill( + "ascii-art", + "ASCII art", + "Render text as ASCII art via pyfiglet.", + &["fun"], + ), + ] +} + +#[test] +fn a_capability_query_finds_the_skill_that_serves_it() { + let all = corpus(); + let hits = rank(&all, "write a changelog from commits", 3); + assert_eq!(id_of(hits[0]), "changelog"); +} + +#[test] +fn a_skill_is_findable_by_its_directory_id_even_when_the_display_name_differs() { + // The reason `dir_name` is in the searchable text. `changelog`'s display + // name is "Release changelog"; the id the model must eventually type is + // `changelog`, and a query using that id must not miss. + let all = corpus(); + let hits = rank(&all, "ascii-art", 3); + assert_eq!(id_of(hits[0]), "ascii-art"); +} + +#[test] +fn tags_are_searchable() { + let all = corpus(); + let hits = rank(&all, "pyfiglet fun", 3); + assert_eq!(id_of(hits[0]), "ascii-art"); +} + +#[test] +fn nothing_relevant_returns_nothing() { + // The property that makes a miss legible. A ranker that always returns + // `limit` entries would hand the model `ascii-art` for a database query + // and invite it to run the thing. + // + // This query is also the stopword regression: before `Bm25Index::significant` + // existed it matched `changelog`, on the strength of the word "a". + let all = corpus(); + assert!(rank(&all, "provision a kubernetes cluster", 3).is_empty()); + assert!(rank(&all, "photosynthesis", 3).is_empty()); +} + +#[test] +fn the_limit_caps_the_result_set() { + let all = corpus(); + let query = "changelog ascii tinyflows"; + assert_eq!( + rank(&all, query, 10).len(), + 3, + "all three must match unlimited" + ); + assert_eq!(rank(&all, query, 1).len(), 1); +} + +#[test] +fn an_empty_corpus_is_safe() { + assert!(rank(&[], "anything", 5).is_empty()); +} + +#[test] +fn the_projection_omits_the_body_and_caps_the_description() { + // The whole point of projecting: `list_workflows` serialises frontmatter, + // resources and location for every skill. If this ever returns the struct + // the tool has stopped saving anything. + let long = "x".repeat(1000); + let workflow = skill("s", "S", &long, &["t"]); + let value = project(&workflow); + let obj = value.as_object().expect("object"); + let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + keys.sort(); + assert_eq!(keys, ["description", "id", "name", "scope", "tags"]); + assert!( + obj["description"].as_str().expect("str").len() <= MAX_DESCRIPTION + 8, + "description must be capped" + ); +} + +#[tokio::test] +async fn an_empty_query_is_an_error_rather_than_every_skill() { + let tmp = tempfile::tempdir().expect("tempdir"); + let config = Config { + workspace_dir: tmp.path().to_path_buf(), + ..Default::default() + }; + let tool = SkillSearchTool::new(Arc::new(config)); + let result = tool + .execute(json!({ "query": " " })) + .await + .expect("dispatch"); + assert!(result.is_error); +} + +#[test] +fn a_miss_says_so_instead_of_returning_a_bare_empty_list() { + // Against `render`, not `execute`. The tool discovers through + // `dirs::home_dir()`, so an `execute`-level miss test ranks against the + // developer's own installed skills — this exact test failed that way, + // matching five real skills in a supposedly empty temp workspace. + let value = render(&[], 14); + assert_eq!(value["matched"], 0); + assert_eq!(value["installed"], 14); + assert!( + value["hint"] + .as_str() + .expect("hint") + .contains("skill_registry_search"), + "a miss must say what to do next" + ); +} + +#[test] +fn a_hit_reports_how_many_were_searched() { + // `installed` is what lets the model tell "nothing matched" from "you have + // three skills and none is close" without a second call. + let all = corpus(); + let hits = rank(&all, "changelog", 5); + let value = render(&hits, all.len()); + assert_eq!(value["matched"], 1); + assert_eq!(value["installed"], 3); + assert!(value["hint"].is_null(), "a hit carries no miss hint"); +} + +#[tokio::test] +async fn the_bundled_skill_is_findable_once_installed() { + // End to end over the real pipeline: materialise the compiled-in bundles + // into a temp workspace, then search. This is the test that would fail if + // discovery stopped scanning the builtin root, if the scope were rejected, + // or if the bundle's frontmatter stopped parsing. + let tmp = tempfile::tempdir().expect("tempdir"); + let report = crate::openhuman::skills::bundled::install(tmp.path()); + assert!(report.failed.is_empty(), "install failed: {report:?}"); + if crate::openhuman::skills::bundled::BUNDLED.is_empty() { + return; // nothing ships in this feature configuration + } + + let config = Config { + workspace_dir: tmp.path().to_path_buf(), + ..Default::default() + }; + let tool = SkillSearchTool::new(Arc::new(config)); + let result = tool + .execute(json!({ "query": "tinyflows expression syntax and node configuration" })) + .await + .expect("dispatch"); + let text = format!("{result:?}"); + assert!( + text.contains("flow-authoring"), + "the bundled skill must be discoverable through search: {text}" + ); +} diff --git a/src/openhuman/skills/stub.rs b/src/openhuman/skills/stub.rs index cf4bacc16d..5682a21752 100644 --- a/src/openhuman/skills/stub.rs +++ b/src/openhuman/skills/stub.rs @@ -56,6 +56,15 @@ pub fn load_workflow_metadata_for_profile( Vec::new() } +/// No-op: with skills compiled out nothing can discover a bundled skill, so +/// writing one into the workspace would only leave files nothing reads. +/// +/// Returns unit rather than the real `InstallReport` — the report type lives +/// inside the gated tree, and the one caller only logs it. +pub fn install_bundled_skills(_workspace_dir: &Path) { + log::debug!("[skills-stub] install_bundled_skills skipped (skills disabled)"); +} + /// No-op success: with skills compiled out there is no skills directory to /// provision, and workspace bootstrap must not fail because of it. pub fn init_workflows_dir(_workspace_dir: &Path) -> Result<(), String> { diff --git a/src/openhuman/skills/tools.rs b/src/openhuman/skills/tools.rs index df406669d8..37c12de16b 100644 --- a/src/openhuman/skills/tools.rs +++ b/src/openhuman/skills/tools.rs @@ -55,16 +55,33 @@ fn read_workflow_id(args: &serde_json::Value) -> anyhow::Result { /// Skill/workflow allowlist applied per agent profile. `None` = all skills are /// visible (the default). `Some(set)` restricts to the named `dir_name` slugs. -type SkillAllowlist = Option>; +pub(super) type SkillAllowlist = Option>; /// Whether `dir_name` passes the optional per-profile skill allowlist. -fn skill_allowed(allowlist: &SkillAllowlist, dir_name: &str) -> bool { +pub(super) fn skill_allowed(allowlist: &SkillAllowlist, dir_name: &str) -> bool { match allowlist { None => true, Some(set) => set.contains(dir_name), } } +/// Whether `skill_id` names a skill compiled into this binary. +/// +/// Builtin bundles are exempt from the per-profile allowlist for the same +/// reason profile-local ones are: the allowlist scopes **user content**, and +/// these are neither the user's nor scoped — they come from a `const` table in +/// this build and one of them (`flow-authoring`) is the reference manual an +/// agent's own system prompt points it at. A profile that narrowed its skills +/// would otherwise leave that agent pointing at a page it is refused. +/// +/// This widens nothing a user chose: no RPC and no config can add a row to that +/// table (see `skills::bundled`), so the exempt set is fixed at compile time. +pub(super) fn is_builtin_skill(skill_id: &str) -> bool { + super::bundled::BUNDLED + .iter() + .any(|s| s.dir_name == skill_id) +} + /// Whether `skill_id` is usable given the profile's allowlist AND its private /// skills. A profile's own (profile-local) skills are implicitly allowed for /// their owner — they bypass the `allowed_skills` allowlist, mirroring @@ -73,9 +90,15 @@ fn skill_allowed(allowlist: &SkillAllowlist, dir_name: &str) -> bool { fn skill_allowed_including_profile( allowlist: &SkillAllowlist, profile_local_ids: &std::collections::HashSet, + workspace_dir: &Path, + profile_skills_root: Option<&Path>, skill_id: &str, ) -> bool { - profile_local_ids.contains(skill_id) || skill_allowed(allowlist, skill_id) + let resolved_scope = get_workflow_with_profile(workspace_dir, skill_id, profile_skills_root) + .map(|workflow| workflow.scope); + matches!(resolved_scope, Some(WorkflowScope::Builtin)) + || profile_local_ids.contains(skill_id) + || skill_allowed(allowlist, skill_id) } /// List installed skills. @@ -175,7 +198,8 @@ impl Tool for WorkflowListTool { // bypass the `allowed_skills` allowlist (which scopes only global // skills). Keep any skill whose scope is `Profile`. workflows.retain(|w| { - w.scope == WorkflowScope::Profile + w.scope == WorkflowScope::Builtin + || w.scope == WorkflowScope::Profile || skill_allowed(&self.skill_allowlist, &w.dir_name) }); log::debug!( @@ -250,7 +274,13 @@ impl Tool for WorkflowDescribeTool { log::debug!("[tool][workflows] describe invoked"); let skill_id = read_workflow_id(&args)?; let profile_local = profile_local_skill_ids(self.profile_skills_root.as_deref()); - if !skill_allowed_including_profile(&self.skill_allowlist, &profile_local, &skill_id) { + if !skill_allowed_including_profile( + &self.skill_allowlist, + &profile_local, + &self.workspace_dir, + self.profile_skills_root.as_deref(), + &skill_id, + ) { log::debug!("[profiles] describe_workflow blocked by profile allowlist: {skill_id}"); return Ok(ToolResult::error(format!( "describe_workflow: workflow `{skill_id}` is not available to the active agent profile" @@ -335,7 +365,13 @@ impl Tool for WorkflowReadResourceTool { log::debug!("[tool][workflows] read_resource invoked"); let skill_id = read_workflow_id(&args)?; let profile_local = profile_local_skill_ids(self.profile_skills_root.as_deref()); - if !skill_allowed_including_profile(&self.skill_allowlist, &profile_local, &skill_id) { + if !skill_allowed_including_profile( + &self.skill_allowlist, + &profile_local, + &self.workspace_dir, + self.profile_skills_root.as_deref(), + &skill_id, + ) { log::debug!( "[profiles] read_workflow_resource blocked by profile allowlist: {skill_id}" ); @@ -444,6 +480,8 @@ impl Tool for WorkflowRecentRunsTool { && skill_allowed_including_profile( &self.skill_allowlist, &profile_local, + &self.workspace_dir, + self.profile_skills_root.as_deref(), &run.workflow_id, ) }) @@ -543,6 +581,8 @@ impl Tool for WorkflowReadRunLogTool { && skill_allowed_including_profile( &self.skill_allowlist, &profile_local, + &self.workspace_dir, + self.profile_skills_root.as_deref(), &run.workflow_id, ) }) diff --git a/src/openhuman/threads/todos/tools.rs b/src/openhuman/threads/todos/tools.rs index b9ae2536d5..a50e9f7f8d 100644 --- a/src/openhuman/threads/todos/tools.rs +++ b/src/openhuman/threads/todos/tools.rs @@ -19,7 +19,7 @@ use async_trait::async_trait; use serde_json::json; use crate::openhuman::config::Config; -use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolExposure, ToolResult}; use super::ops::{self, BoardLocation, CardPatch, TodosSnapshot}; @@ -114,6 +114,17 @@ impl TodoListTool { #[async_trait] impl Tool for TodoListTool { + /// Superseded by the `todo` tool's `op` dispatch, which covers every + /// operation this family offers. Kept registered and dispatchable so a + /// replayed transcript, a saved skill, or a model working from a cached + /// prompt that still names `todo_*` keeps working; hidden from the wire so + /// nine schemas do not ship where one does the job. + /// + /// Delete this family once no live transcript names it. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "todo_list" } @@ -156,6 +167,17 @@ impl TodoAddTool { #[async_trait] impl Tool for TodoAddTool { + /// Superseded by the `todo` tool's `op` dispatch, which covers every + /// operation this family offers. Kept registered and dispatchable so a + /// replayed transcript, a saved skill, or a model working from a cached + /// prompt that still names `todo_*` keeps working; hidden from the wire so + /// nine schemas do not ship where one does the job. + /// + /// Delete this family once no live transcript names it. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "todo_add" } @@ -217,6 +239,17 @@ impl TodoEditTool { #[async_trait] impl Tool for TodoEditTool { + /// Superseded by the `todo` tool's `op` dispatch, which covers every + /// operation this family offers. Kept registered and dispatchable so a + /// replayed transcript, a saved skill, or a model working from a cached + /// prompt that still names `todo_*` keeps working; hidden from the wire so + /// nine schemas do not ship where one does the job. + /// + /// Delete this family once no live transcript names it. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "todo_edit" } @@ -277,6 +310,17 @@ impl TodoUpdateStatusTool { #[async_trait] impl Tool for TodoUpdateStatusTool { + /// Superseded by the `todo` tool's `op` dispatch, which covers every + /// operation this family offers. Kept registered and dispatchable so a + /// replayed transcript, a saved skill, or a model working from a cached + /// prompt that still names `todo_*` keeps working; hidden from the wire so + /// nine schemas do not ship where one does the job. + /// + /// Delete this family once no live transcript names it. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "todo_update_status" } @@ -329,6 +373,17 @@ impl TodoDecidePlanTool { #[async_trait] impl Tool for TodoDecidePlanTool { + /// Superseded by the `todo` tool's `op` dispatch, which covers every + /// operation this family offers. Kept registered and dispatchable so a + /// replayed transcript, a saved skill, or a model working from a cached + /// prompt that still names `todo_*` keeps working; hidden from the wire so + /// nine schemas do not ship where one does the job. + /// + /// Delete this family once no live transcript names it. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "todo_decide_plan" } @@ -383,6 +438,17 @@ impl TodoRemoveTool { #[async_trait] impl Tool for TodoRemoveTool { + /// Superseded by the `todo` tool's `op` dispatch, which covers every + /// operation this family offers. Kept registered and dispatchable so a + /// replayed transcript, a saved skill, or a model working from a cached + /// prompt that still names `todo_*` keeps working; hidden from the wire so + /// nine schemas do not ship where one does the job. + /// + /// Delete this family once no live transcript names it. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "todo_remove" } @@ -433,6 +499,17 @@ impl TodoReplaceTool { #[async_trait] impl Tool for TodoReplaceTool { + /// Superseded by the `todo` tool's `op` dispatch, which covers every + /// operation this family offers. Kept registered and dispatchable so a + /// replayed transcript, a saved skill, or a model working from a cached + /// prompt that still names `todo_*` keeps working; hidden from the wire so + /// nine schemas do not ship where one does the job. + /// + /// Delete this family once no live transcript names it. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "todo_replace" } @@ -491,6 +568,17 @@ impl TodoClearTool { #[async_trait] impl Tool for TodoClearTool { + /// Superseded by the `todo` tool's `op` dispatch, which covers every + /// operation this family offers. Kept registered and dispatchable so a + /// replayed transcript, a saved skill, or a model working from a cached + /// prompt that still names `todo_*` keeps working; hidden from the wire so + /// nine schemas do not ship where one does the job. + /// + /// Delete this family once no live transcript names it. + fn exposure(&self) -> ToolExposure { + ToolExposure::Hidden + } + fn name(&self) -> &str { "todo_clear" } diff --git a/src/openhuman/threads/transcript_view/transcript_view_tests.rs b/src/openhuman/threads/transcript_view/transcript_view_tests.rs index 2532c95420..a5576ee794 100644 --- a/src/openhuman/threads/transcript_view/transcript_view_tests.rs +++ b/src/openhuman/threads/transcript_view/transcript_view_tests.rs @@ -410,6 +410,7 @@ fn tool_failure_metadata_round_trips_write_to_display_line() { role: "tool".into(), content: r#"{"tool_call_id":"call-1","content":"boom"}"#.into(), extra_metadata: None, + cache_breakpoints: Vec::new(), }; transcript::attach_tool_failure_metadata(&mut tool_msg, Some("boom: exit 1")); @@ -419,6 +420,7 @@ fn tool_failure_metadata_round_trips_write_to_display_line() { role: "user".into(), content: "do it".into(), extra_metadata: None, + cache_breakpoints: Vec::new(), }, tool_msg, ]; @@ -522,6 +524,7 @@ fn append_transcript_turn_projects_full_display_shape() { role: role.into(), content: content.into(), extra_metadata: None, + cache_breakpoints: Vec::new(), }; let first = vec![ diff --git a/src/openhuman/tools/impl/meta/collapse.rs b/src/openhuman/tools/impl/meta/collapse.rs new file mode 100644 index 0000000000..1a5a63d14f --- /dev/null +++ b/src/openhuman/tools/impl/meta/collapse.rs @@ -0,0 +1,210 @@ +//! Building blocks for collapsing a family of tools into one action-dispatched +//! tool. +//! +//! # Why collapse +//! +//! Every tool on the wire costs its name, its description and its full +//! parameter schema on every request. A family of six CRUD tools over one +//! resource pays that six times to say almost the same thing: `cron_list`, +//! `cron_add`, `cron_update`, `cron_remove`, `cron_run` and `cron_runs` were +//! 3,938 bytes between them, and four of the six are a `job_id` and nothing +//! else. Hermes reaches the same conclusion from the other direction — its +//! whole scheduler surface is a single `cronjob` tool, its whole memory surface +//! a single `memory`. +//! +//! # The two rules that make this safe +//! +//! Collapsing merges tools that the security layer had been judging +//! separately, and getting that wrong is how a token optimisation becomes a +//! privilege bug. So: +//! +//! 1. **The parameter schema is merged from the members, never retyped.** A +//! hand-written union drifts the moment a member gains a field, and the +//! drift is silent: the model is told about a parameter the implementation +//! ignores, or not told about one it needs. [`merge_action_schemas`] derives +//! it from the same `parameters_schema()` the members serve. +//! 2. **Permission is per action, and the argument-free answer is the +//! strictest.** [`Tool::permission_level`] has no arguments, so a collapsed +//! tool cannot answer it honestly; it returns the strictest level any member +//! requires, and [`Tool::permission_level_with_args`] gives the exact one +//! once the action is known. A caller that ignores the arguments therefore +//! over-restricts rather than under-restricts. +//! +//! The same reasoning applies to [`Tool::external_effect`], which has no +//! argument-aware variant at all: a collapsed tool reports `true` if *any* +//! member does. + +use std::collections::BTreeMap; + +use serde_json::{json, Map, Value}; + +use crate::openhuman::tools::{PermissionLevel, Tool}; + +/// One member of a collapsed family: the action name the model passes, and the +/// tool that serves it. +pub struct CollapsedAction<'a> { + pub action: &'static str, + pub tool: &'a dyn Tool, +} + +/// Build the collapsed `parameters_schema` from the members' own schemas. +/// +/// The result is an object with `action` (a required enum over the member +/// names) plus the union of every member's properties. Property descriptions +/// are prefixed with the action they belong to — the convention `memory_tree` +/// and `todo` already use — so the model can tell which fields apply to the +/// action it picked. +/// +/// Nothing is `required` beyond `action`. A union cannot express "required for +/// this action only", and marking a field required because one action needs it +/// would make every other action's call invalid. The members already validate +/// their own required arguments and return a useful error, so the check lives +/// where it can be specific rather than in a schema that has to be vague. +pub fn merge_action_schemas(actions: &[CollapsedAction<'_>]) -> Value { + let mut properties: BTreeMap = BTreeMap::new(); + // Track which actions mentioned each property so a shared field reads as + // shared rather than as belonging to whichever action happened to be first. + let mut owners: BTreeMap> = BTreeMap::new(); + + for entry in actions { + let schema = entry.tool.parameters_schema(); + let Some(props) = schema.get("properties").and_then(Value::as_object) else { + continue; + }; + for (name, spec) in props { + owners.entry(name.clone()).or_default().push(entry.action); + properties + .entry(name.clone()) + .or_insert_with(|| spec.clone()); + } + } + + // Rewrite each description to name its actions. Done in a second pass so + // the prefix can list every owner, which the first pass does not yet know. + for (name, spec) in properties.iter_mut() { + let Some(object) = spec.as_object_mut() else { + continue; + }; + let owned_by = owners.get(name).map(Vec::as_slice).unwrap_or(&[]); + // A property every action takes needs no prefix — saying so would be + // noise on every line. + if owned_by.len() == actions.len() || owned_by.is_empty() { + continue; + } + let prefix = owned_by.join("/"); + let existing = object + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let described = if existing.is_empty() { + prefix + } else { + format!("{prefix}: {existing}") + }; + object.insert("description".to_string(), Value::String(described)); + } + + let enum_values: Vec = actions + .iter() + .map(|entry| Value::String(entry.action.to_string())) + .collect(); + + let mut merged = Map::new(); + merged.insert( + "action".to_string(), + json!({ + "type": "string", + "enum": enum_values, + "description": "Which operation to run." + }), + ); + for (name, spec) in properties { + merged.insert(name, spec); + } + + json!({ + "type": "object", + "properties": Value::Object(merged), + "required": ["action"] + }) +} + +/// The strictest permission level any member requires. +/// +/// Used for the argument-free [`Tool::permission_level`], which cannot know +/// which action is coming. Over-restricting is the only safe direction. +pub fn strictest_permission(actions: &[CollapsedAction<'_>]) -> PermissionLevel { + actions + .iter() + .map(|entry| entry.tool.permission_level()) + .max_by_key(permission_rank) + .unwrap_or(PermissionLevel::None) +} + +/// `true` when any member has an external effect. +pub fn any_external_effect(actions: &[CollapsedAction<'_>]) -> bool { + actions.iter().any(|entry| entry.tool.external_effect()) +} + +/// Order the permission levels from least to most privileged. +/// +/// `PermissionLevel` does derive `Ord` over explicit discriminants, so `.max()` +/// would work today. This exhaustive match is here for the day it gains a +/// variant: a new level would compile fine against `.max()` and silently take +/// whatever rank its discriminant implied, whereas here it is a compile error +/// until someone decides where it sits. Getting that wrong under-restricts a +/// collapsed tool, which is the failure this module exists to avoid. +fn permission_rank(level: &PermissionLevel) -> u8 { + match level { + PermissionLevel::None => 0, + PermissionLevel::ReadOnly => 1, + PermissionLevel::Write => 2, + PermissionLevel::Execute => 3, + PermissionLevel::Dangerous => 4, + } +} + +/// Find the member serving `action`. +pub fn resolve<'a>( + actions: &'a [CollapsedAction<'a>], + action: &str, +) -> Option<&'a CollapsedAction<'a>> { + actions.iter().find(|entry| entry.action == action) +} + +/// The error a collapsed tool returns for an unknown or missing action. +/// +/// Lists the valid actions, because the model's next move after this message is +/// to guess, and a guess against a printed list is far more likely to be right. +pub fn unknown_action_message(actions: &[CollapsedAction<'_>], got: Option<&str>) -> String { + let valid = actions + .iter() + .map(|entry| entry.action) + .collect::>() + .join("|"); + match got { + Some(other) => format!("unknown action '{other}' (expected {valid})"), + None => format!("missing required field `action` (expected {valid})"), + } +} + +/// Strip the dispatch key before forwarding to the member. +/// +/// The members are the same tools that serve the legacy names, and several of +/// them set `"additionalProperties": false`; leaving `action` in the object +/// would be rejected by any validation they do. +pub fn args_without_action(args: &Value) -> Value { + match args.as_object() { + Some(object) => { + let mut cloned = object.clone(); + cloned.remove("action"); + Value::Object(cloned) + } + None => args.clone(), + } +} + +#[cfg(test)] +#[path = "collapse_tests.rs"] +mod tests; diff --git a/src/openhuman/tools/impl/meta/collapse_tests.rs b/src/openhuman/tools/impl/meta/collapse_tests.rs new file mode 100644 index 0000000000..ff79b1c635 --- /dev/null +++ b/src/openhuman/tools/impl/meta/collapse_tests.rs @@ -0,0 +1,208 @@ +use super::*; +use crate::openhuman::tools::ToolResult; +use async_trait::async_trait; + +struct Stub { + name: &'static str, + schema: Value, + permission: PermissionLevel, + external: bool, +} + +#[async_trait] +impl Tool for Stub { + fn name(&self) -> &str { + self.name + } + fn description(&self) -> &str { + "stub" + } + fn parameters_schema(&self) -> Value { + self.schema.clone() + } + fn permission_level(&self) -> PermissionLevel { + self.permission + } + fn external_effect(&self) -> bool { + self.external + } + async fn execute(&self, _args: Value) -> anyhow::Result { + Ok(ToolResult::success("ok")) + } +} + +fn stub(name: &'static str, schema: Value, permission: PermissionLevel, external: bool) -> Stub { + Stub { + name, + schema, + permission, + external, + } +} + +#[test] +fn the_union_carries_every_members_properties() { + let list = stub( + "list", + json!({"type": "object", "properties": {}}), + PermissionLevel::ReadOnly, + false, + ); + let runs = stub( + "runs", + json!({"type": "object", "properties": { + "job_id": {"type": "string"}, + "limit": {"type": "integer", "description": "How many."} + }}), + PermissionLevel::ReadOnly, + false, + ); + let actions = vec![ + CollapsedAction { + action: "list", + tool: &list, + }, + CollapsedAction { + action: "runs", + tool: &runs, + }, + ]; + let merged = merge_action_schemas(&actions); + let props = merged["properties"].as_object().expect("properties"); + assert!(props.contains_key("action")); + assert!(props.contains_key("job_id")); + assert!(props.contains_key("limit")); + assert_eq!(merged["required"], json!(["action"])); +} + +#[test] +fn only_action_is_required_because_a_union_cannot_say_otherwise() { + // `job_id` is required for `runs` and meaningless for `list`. Marking + // it required here would make every `list` call invalid. + let list = stub( + "list", + json!({"type": "object", "properties": {}}), + PermissionLevel::ReadOnly, + false, + ); + let runs = stub( + "runs", + json!({"type": "object", "properties": {"job_id": {"type": "string"}}, "required": ["job_id"]}), + PermissionLevel::ReadOnly, + false, + ); + let actions = vec![ + CollapsedAction { + action: "list", + tool: &list, + }, + CollapsedAction { + action: "runs", + tool: &runs, + }, + ]; + assert_eq!( + merge_action_schemas(&actions)["required"], + json!(["action"]) + ); +} + +#[test] +fn a_property_only_some_actions_take_is_labelled_with_them() { + let a = stub( + "a", + json!({"type": "object", "properties": {"shared": {"type": "string"}}}), + PermissionLevel::ReadOnly, + false, + ); + let b = stub( + "b", + json!({"type": "object", "properties": { + "shared": {"type": "string"}, + "only_b": {"type": "string", "description": "B's field."} + }}), + PermissionLevel::ReadOnly, + false, + ); + let actions = vec![ + CollapsedAction { + action: "a", + tool: &a, + }, + CollapsedAction { + action: "b", + tool: &b, + }, + ]; + let merged = merge_action_schemas(&actions); + let props = &merged["properties"]; + assert_eq!(props["only_b"]["description"], json!("b: B's field.")); + // Taken by every action, so no prefix — it would be noise. + assert!(props["shared"].get("description").is_none()); +} + +#[test] +fn permission_is_the_strictest_member_not_the_first() { + let read = stub("r", json!({}), PermissionLevel::ReadOnly, false); + let execute = stub("x", json!({}), PermissionLevel::Execute, false); + let write = stub("w", json!({}), PermissionLevel::Write, false); + let actions = vec![ + CollapsedAction { + action: "r", + tool: &read, + }, + CollapsedAction { + action: "x", + tool: &execute, + }, + CollapsedAction { + action: "w", + tool: &write, + }, + ]; + assert_eq!(strictest_permission(&actions), PermissionLevel::Execute); +} + +#[test] +fn external_effect_is_true_when_any_member_has_one() { + let clean = stub("c", json!({}), PermissionLevel::ReadOnly, false); + let dirty = stub("d", json!({}), PermissionLevel::ReadOnly, true); + assert!(!any_external_effect(&[CollapsedAction { + action: "c", + tool: &clean + }])); + assert!(any_external_effect(&[ + CollapsedAction { + action: "c", + tool: &clean + }, + CollapsedAction { + action: "d", + tool: &dirty + }, + ])); +} + +#[test] +fn the_dispatch_key_does_not_reach_the_member() { + // Several members set `additionalProperties: false`. + let args = json!({"action": "runs", "job_id": "j1"}); + assert_eq!(args_without_action(&args), json!({"job_id": "j1"})); +} + +#[test] +fn an_unknown_action_names_the_valid_ones() { + let a = stub("a", json!({}), PermissionLevel::ReadOnly, false); + let actions = vec![CollapsedAction { + action: "add", + tool: &a, + }]; + assert_eq!( + unknown_action_message(&actions, Some("addd")), + "unknown action 'addd' (expected add)" + ); + assert_eq!( + unknown_action_message(&actions, None), + "missing required field `action` (expected add)" + ); +} diff --git a/src/openhuman/tools/impl/meta/mod.rs b/src/openhuman/tools/impl/meta/mod.rs new file mode 100644 index 0000000000..282404f620 --- /dev/null +++ b/src/openhuman/tools/impl/meta/mod.rs @@ -0,0 +1,18 @@ +//! Tools *about* the tool surface itself. +//! +//! Two members: [`tool_search`], the lookup half of +//! [`ToolExposure::Deferred`](crate::openhuman::tools::ToolExposure). It sits +//! in its own family rather than under `system/` because it is not a capability +//! the host offers the user — it is the model asking what it is able to do. + +pub mod collapse; +pub mod tool_search; + +pub use collapse::{ + any_external_effect, args_without_action, merge_action_schemas, resolve, strictest_permission, + unknown_action_message, CollapsedAction, +}; +pub use tool_search::{ + bind_tool_search_index, strip_deferred_from_visible, ToolSearchHandle, ToolSearchIndex, + ToolSearchTool, TOOL_SEARCH_NAME, +}; diff --git a/src/openhuman/tools/impl/meta/tool_search.rs b/src/openhuman/tools/impl/meta/tool_search.rs new file mode 100644 index 0000000000..d7e8aca9b1 --- /dev/null +++ b/src/openhuman/tools/impl/meta/tool_search.rs @@ -0,0 +1,329 @@ +//! `tool_search` — look up a capability whose schema is not on the wire. +//! +//! # Why this exists +//! +//! Tool schemas are a fixed cost paid on every request. Measured on the +//! orchestrator with an empty workspace, they were 45,199 bytes against 33,808 +//! bytes of system prompt; on a signed-in workspace with Composio connected +//! they reached ~112 KB. Most of that is tools the model reaches for on a +//! handful of turns a week. +//! +//! [`ToolExposure::Deferred`] takes such a tool off the wire without removing +//! the capability, and this is the other half of that bargain: the model can +//! find it again. A capability the model can neither see nor look up is simply +//! gone, which is a far worse regression than the tokens it saves. +//! +//! # Why not the toolpack mechanism +//! +//! Packs answer a different question. A pack is a *group* withheld by a config +//! posture, recovered through `load_skill`, and its membership is compiled in +//! precisely so config cannot move a dangerous tool out of the reviewed +//! surface. That is the right shape for compressing a belt an agent owns. +//! +//! Deferral is per-tool and is a property of the tool: `stock_quote` is rarely +//! needed whoever is running the host. The two compose — a deferred tool inside +//! a withheld pack is simply absent twice — and neither can widen the surface, +//! because both only ever subtract from a set the belt and the security policy +//! already decided. +//! +//! # Ranking +//! +//! A hand-rolled BM25. Codex uses the `bm25` crate for the same job; this crate +//! is kernel surface under a dependency-floor ratchet +//! (`scripts/kernel-floor.limits`), and ~70 lines of arithmetic is a better +//! trade than a package on the floor of every embedder's build. + +use std::sync::{Arc, RwLock}; + +use anyhow::Result; +use serde_json::{json, Value}; + +use crate::openhuman::tools::{PermissionLevel, Tool, ToolCategory, ToolResult, ToolSpec}; +use crate::openhuman::util::bm25::Bm25Index; + +/// How many matches a search returns when the caller does not say. +const DEFAULT_LIMIT: usize = 5; +/// Ceiling on `limit`, so one call cannot undo the saving by asking for +/// everything. +const MAX_LIMIT: usize = 20; + +/// One deferred tool, as the index sees it. +#[derive(Clone, Debug)] +pub struct SearchableTool { + pub name: String, + pub description: String, + pub parameters: Value, + /// `name` + `description`, the text the ranker sees. + searchable: String, +} + +impl SearchableTool { + pub fn from_spec(spec: &ToolSpec) -> Self { + Self { + name: spec.name.clone(), + description: spec.description.clone(), + parameters: spec.parameters.clone(), + searchable: format!("{} {}", spec.name, spec.description), + } + } +} + +/// A BM25 index over the deferred tools. +/// +/// A thin adapter: the ranking lives in [`crate::openhuman::util::bm25`], which +/// knows nothing about tools, and this type owns the part that is about tools — +/// what text is searchable and what a result looks like. +#[derive(Default, Debug)] +pub struct ToolSearchIndex { + tools: Vec, + index: Bm25Index, +} + +impl ToolSearchIndex { + pub fn build(specs: &[ToolSpec]) -> Self { + let tools: Vec = specs.iter().map(SearchableTool::from_spec).collect(); + let index = Bm25Index::build( + tools + .iter() + .map(|t| (t.name.as_str(), t.searchable.as_str())) + .collect::>(), + ); + Self { tools, index } + } + + pub fn is_empty(&self) -> bool { + self.tools.is_empty() + } + + pub fn len(&self) -> usize { + self.tools.len() + } + + /// Rank the index against `query`, best first. + /// + /// Only tools scoring above zero are returned. Padding the list out to + /// `limit` with unrelated tools would spend exactly the tokens this + /// mechanism exists to save, and would invite the model to call something + /// that has nothing to do with what it asked for. + pub fn search(&self, query: &str, limit: usize) -> Vec<&SearchableTool> { + self.index + .search(query, limit) + .into_iter() + .map(|i| &self.tools[i]) + .collect() + } +} + +/// Shared handle to the index. +/// +/// The tool is constructed during registry assembly, before anything knows +/// which tools will end up deferred — that depends on the agent's belt, which +/// is resolved later in the session builder. So the tool owns an empty index +/// and the builder fills it in through [`bind_tool_search_index`], the same +/// two-step shape `toolpacks::bind_pack_registry` already uses. +pub type ToolSearchHandle = Arc>; + +/// Look up a deferred capability by description. +pub struct ToolSearchTool { + index: ToolSearchHandle, +} + +impl Default for ToolSearchTool { + fn default() -> Self { + Self::new() + } +} + +impl ToolSearchTool { + pub fn new() -> Self { + Self { + index: Arc::new(RwLock::new(ToolSearchIndex::default())), + } + } +} + +#[async_trait::async_trait] +impl Tool for ToolSearchTool { + fn name(&self) -> &str { + TOOL_SEARCH_NAME + } + + fn description(&self) -> &str { + "Find a tool that is not in your tool list. Not every capability this \ + host has is advertised up front; describe what you need in plain words \ + (\"send a calendar invite\", \"read a PDF\") and this returns the \ + matching tools with their full argument schemas, which you can then \ + call directly. Use it before telling the user something is impossible." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "What you need to do, in plain words." + }, + "limit": { + "type": "integer", + "description": format!( + "How many matches to return (default {DEFAULT_LIMIT}, max {MAX_LIMIT})." + ), + "minimum": 1, + "maximum": MAX_LIMIT + } + }, + "required": ["query"] + }) + } + + fn category(&self) -> ToolCategory { + ToolCategory::System + } + + /// Exposes the index so [`bind_tool_search_index`] can populate it after + /// the agent's belt is resolved. + /// + /// Erased for the same reason `PackRegistryHandle` is: a `ToolSearchIndex` + /// is this host's concept, and a vocabulary shared with other hosts has no + /// business naming it. + fn host_extension(&self) -> Option<&(dyn std::any::Any + Send + Sync)> { + Some(&self.index) + } + + fn permission_level(&self) -> PermissionLevel { + // Reads a static in-memory index. Calling it cannot change anything, + // and gating it would put an approval prompt in front of the model + // merely asking what it is allowed to do. + PermissionLevel::None + } + + async fn execute(&self, args: Value) -> Result { + let query = args + .get("query") + .and_then(Value::as_str) + .unwrap_or_default() + .trim() + .to_string(); + if query.is_empty() { + return Ok(ToolResult::error( + "tool_search needs a `query` describing what you want to do.", + )); + } + let limit = args + .get("limit") + .and_then(Value::as_u64) + .map(|n| (n as usize).clamp(1, MAX_LIMIT)) + .unwrap_or(DEFAULT_LIMIT); + + let index = self + .index + .read() + .map_err(|_| anyhow::anyhow!("tool search index lock poisoned"))?; + + let matches = index.search(&query, limit); + if matches.is_empty() { + return Ok(ToolResult::success(format!( + "No deferred tool matches \"{query}\". {} tools are searchable; \ + everything else you can use is already in your tool list.", + index.len() + ))); + } + let payload: Vec = matches + .iter() + .map(|tool| { + json!({ + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters, + }) + }) + .collect(); + Ok(ToolResult::success(format!( + "{} match(es). Call any of these by name, using the parameters shown.\n{}", + payload.len(), + serde_json::to_string_pretty(&payload)? + ))) + } +} + +#[cfg(test)] +#[path = "tool_search_tests.rs"] +mod tests; + +/// Remove every [`ToolExposure::Deferred`] and [`ToolExposure::Hidden`] tool +/// from an agent's advertised set, returning the specs of the deferred ones so +/// the caller can index them. +/// +/// Hidden tools are dropped and **not** returned: they are not searchable +/// either, by definition. +/// +/// `tool_search` itself is never removed, whatever it declares. A search tool +/// the model cannot see is the one failure mode this whole mechanism cannot +/// recover from — every deferred capability would be unreachable, silently. +pub fn strip_deferred_from_visible( + visible: &mut std::collections::HashSet, + tools: &[Box], +) -> Vec { + use crate::openhuman::tools::ToolExposure; + + let mut deferred = Vec::new(); + for tool in tools { + let name = tool.name(); + if name == TOOL_SEARCH_NAME || !visible.contains(name) { + continue; + } + match tool.exposure() { + ToolExposure::Direct => {} + ToolExposure::Deferred => { + visible.remove(name); + deferred.push(tool.spec()); + } + ToolExposure::Hidden => { + visible.remove(name); + } + } + } + deferred +} + +/// The advertised name of [`ToolSearchTool`], as a constant so the carve-out +/// above and the registration site cannot disagree about it. +pub const TOOL_SEARCH_NAME: &str = "tool_search"; + +/// Populate the registry's `tool_search` index with the deferred specs. +/// +/// Returns `false` when the registry has no `tool_search` — which is not an +/// error: an agent whose belt defers nothing does not need one, and a build +/// with the tool compiled out is a legitimate configuration. It **is** worth a +/// warning when specs were deferred and there is nowhere to index them, because +/// that combination makes capabilities unreachable. +pub fn bind_tool_search_index(tools: &[Box], deferred: Vec) -> bool { + let handle = tools.iter().find_map(|tool| { + if tool.name() != TOOL_SEARCH_NAME { + return None; + } + tool.host_extension() + .and_then(|any| any.downcast_ref::()) + }); + let Some(handle) = handle else { + if !deferred.is_empty() { + tracing::warn!( + deferred = deferred.len(), + "[tool_search] tools were deferred but no tool_search is registered; \ + they are unreachable this session" + ); + } + return false; + }; + match handle.write() { + Ok(mut index) => { + *index = ToolSearchIndex::build(&deferred); + true + } + Err(_) => { + tracing::error!("[tool_search] index lock poisoned; leaving it empty"); + false + } + } +} diff --git a/src/openhuman/tools/impl/meta/tool_search_tests.rs b/src/openhuman/tools/impl/meta/tool_search_tests.rs new file mode 100644 index 0000000000..ac4497d595 --- /dev/null +++ b/src/openhuman/tools/impl/meta/tool_search_tests.rs @@ -0,0 +1,85 @@ +// The tokenizer's own tests live with it, in `util::bm25`. +use super::*; + +fn spec(name: &str, description: &str) -> ToolSpec { + ToolSpec { + name: name.to_string(), + description: description.to_string(), + parameters: json!({"type": "object"}), + } +} + +fn index() -> ToolSearchIndex { + ToolSearchIndex::build(&[ + spec("stock_quote", "Get the latest price for a stock ticker"), + spec("cron_add", "Schedule a recurring job to run later"), + spec( + "memory_hybrid_search", + "Search stored memories semantically", + ), + spec( + "generate_presentation", + "Build a pptx slide deck from an outline", + ), + ]) +} + +#[test] +fn a_plain_language_query_finds_the_right_tool() { + let index = index(); + let hits = index.search("schedule something to run every morning", 3); + assert_eq!(hits.first().map(|t| t.name.as_str()), Some("cron_add")); +} + +#[test] +fn a_query_matching_the_name_rather_than_the_description_still_hits() { + let index = index(); + let hits = index.search("stock", 3); + assert_eq!(hits.first().map(|t| t.name.as_str()), Some("stock_quote")); +} + +#[test] +fn nothing_relevant_returns_nothing_rather_than_padding_to_the_limit() { + // Padding would spend exactly the tokens deferral saves, and would + // invite a call to something unrelated to the ask. + let index = index(); + assert!(index.search("xyzzy quantum flux", 5).is_empty()); +} + +#[test] +fn results_are_capped_at_the_requested_limit() { + let index = index(); + assert!(index.search("search a stock job memory slide", 2).len() <= 2); +} + +#[test] +fn an_empty_query_matches_nothing() { + let index = index(); + assert!(index.search(" ", 5).is_empty()); +} + +#[test] +fn an_empty_index_is_searchable_without_panicking() { + // `average_length` is 0 here; the length-normalisation term divides by + // it, so this is the case that would panic or produce NaN if the + // `.max(1.0)` guard were dropped. + let empty = ToolSearchIndex::build(&[]); + assert!(empty.is_empty()); + assert!(empty.search("anything", 5).is_empty()); +} + +#[test] +fn ranking_is_stable_across_identical_queries() { + let index = index(); + let first: Vec<&str> = index + .search("search", 4) + .iter() + .map(|t| t.name.as_str()) + .collect(); + let second: Vec<&str> = index + .search("search", 4) + .iter() + .map(|t| t.name.as_str()) + .collect(); + assert_eq!(first, second); +} diff --git a/src/openhuman/tools/impl/mod.rs b/src/openhuman/tools/impl/mod.rs index 8d1ad608c8..2581d5b3e9 100644 --- a/src/openhuman/tools/impl/mod.rs +++ b/src/openhuman/tools/impl/mod.rs @@ -2,6 +2,7 @@ pub mod browser; #[cfg(feature = "documents")] pub mod document; pub mod filesystem; +pub mod meta; pub mod network; #[cfg(feature = "documents")] pub mod presentation; diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 6c082e805a..49b8a51770 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -68,7 +68,7 @@ pub use schemas::{ all_registered_controllers as all_tools_registered_controllers, }; pub use traits::{ - PermissionLevel, Tool, ToolCallOptions, ToolCategory, ToolContent, ToolResult, ToolScope, - ToolSpec, + PermissionLevel, Tool, ToolCallOptions, ToolCategory, ToolContent, ToolExposure, ToolResult, + ToolScope, ToolSpec, }; pub(crate) use user_filter::filter_tools_by_user_preference; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 9a35a12b21..0f4ef955c7 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -1437,7 +1437,7 @@ fn tool_group(name: &str) -> crate::core::all::DomainGroup { /// `goals_*` was the third until #5560 routed it onto the guarded /// `MemoryGoals` family — the advertised capability did not change when /// the plumbing caught up: the exact property this clause protects. -fn tool_capability(name: &str) -> Option { +pub(crate) fn tool_capability(name: &str) -> Option { use tinymemory_api::capabilities::Capability; // Not driver-backed. Each entry is an argued exception, not a fallthrough. diff --git a/src/openhuman/tools/toolpacks/ops.rs b/src/openhuman/tools/toolpacks/ops.rs index 6fa82c7243..7c54fd5d07 100644 --- a/src/openhuman/tools/toolpacks/ops.rs +++ b/src/openhuman/tools/toolpacks/ops.rs @@ -116,3 +116,25 @@ pub fn strip_packed_from_visible(visible: &mut HashSet, agent_id: &str) "[toolpacks] withheld packed tool schemas; use_skill advertised instead" ); } + +/// Is `tool` withheld from `agent_id` by the pack table right now? +/// +/// The predicate behind [`strip_packed_from_visible`], exposed for callers that +/// build a *listing* of tools rather than a visible set — today the collapsed +/// `delegate_to` tool, whose `agent` enum is an advertised surface that no +/// `visible` subtraction can reach. +/// +/// That distinction is load-bearing. Collapsing the archetype delegates without +/// it silently re-advertised seven routes the pack table deliberately withholds +/// (`do_crypto`, `setup_mcp_server`, `use_mcp_server`, `setup_skills`, +/// `run_skill`, `build_workflow`, `discover_workflows`): each one stopped being +/// a tool — so `strip_packed_from_visible` had nothing to remove — and became a +/// string inside another tool's schema instead. A collapse must never widen +/// what the pack posture narrowed. +pub fn is_withheld_from(agent_id: &str, tool: &str) -> bool { + let groups = super::groups::current(); + groups.mode_for_tool(tool) == super::groups::GroupMode::Withheld + && registry::packed_tool_names_for_agent(agent_id) + .into_iter() + .any(|name| name == tool) +} diff --git a/src/openhuman/tools/toolpacks/registry.rs b/src/openhuman/tools/toolpacks/registry.rs index 4ac4ff9345..c7b744214d 100644 --- a/src/openhuman/tools/toolpacks/registry.rs +++ b/src/openhuman/tools/toolpacks/registry.rs @@ -122,8 +122,18 @@ pub const PACKS: &[ToolPack] = &[ }, ToolPack { id: "skills", - summary: "Find, install and run agent skills from the community registries.", + summary: "Search installed skills; install and run more from community \ + registries.", tools: &[ + // In the pack, not outside it. A search tool advertised while every + // tool it hands off to (`describe_workflow`, `run_skill`) stays + // withheld would cost 748 B on every wildcard agent to produce an id + // the agent then cannot act on without a `load_skill` anyway. One + // recovery step for the whole family beats a doorway to a locked + // room. The orchestrator prompt names it for the same reason it + // already names `describe_workflow` and `skill_registry_browse` — + // those are packed too. + "skill_search", "run_skill", "setup_skills", "skill_registry_browse", @@ -143,6 +153,17 @@ pub const PACKS: &[ToolPack] = &[ "skill_executor", "skill_creator", "context_scout", + // `workflow_builder` owns exactly ONE tool from this pack — + // `read_workflow_resource`, which fetches a page of the + // `flow-authoring` builtin skill, the reference manual its own + // system prompt points it at. Ownership here advertises nothing + // else: its belt is `ToolScope::Named`, so `visible` holds only the + // 30 tools its `agent.toml` lists, and the other ten members of + // this pack are not among them. Without it the manual costs a + // `load_skill` round trip before every read, and the recovery pair + // (`load_skill` + `use_skill`, 3,137 B) lands on the belt in place + // of the tool's own ~500 B — measured, not estimated. + "workflow_builder", ], }, ToolPack { diff --git a/src/openhuman/tools/toolpacks/toolpacks_tests.rs b/src/openhuman/tools/toolpacks/toolpacks_tests.rs index e536529df6..10a4a5c413 100644 --- a/src/openhuman/tools/toolpacks/toolpacks_tests.rs +++ b/src/openhuman/tools/toolpacks/toolpacks_tests.rs @@ -507,6 +507,12 @@ fn every_pack_declares_the_tools_it_is_named_for() { ( "skills", &[ + // Ranked lookup over installed skills. Deliberately IN this + // pack rather than advertised: on its own it produced ids for + // skills whose `describe_workflow` / `run_skill` were still + // withheld — 748 B on every wildcard agent for a doorway to a + // locked room. + "skill_search", "run_skill", "setup_skills", "skill_registry_browse", diff --git a/src/openhuman/tools/traits.rs b/src/openhuman/tools/traits.rs index 92e4bb9379..a0dfc8f666 100644 --- a/src/openhuman/tools/traits.rs +++ b/src/openhuman/tools/traits.rs @@ -21,7 +21,8 @@ pub use tinytools::{ context_detail_from_args, humanize_tool_name, PermissionLevel, Tool, ToolCallOptions, - ToolCategory, ToolContent, ToolResult, ToolRunContext, ToolScope, ToolSpec, ToolTimeout, + ToolCategory, ToolContent, ToolExposure, ToolResult, ToolRunContext, ToolScope, ToolSpec, + ToolTimeout, }; use crate::openhuman::agent::orchestration::tools::DelegationTarget; diff --git a/src/openhuman/util/bm25.rs b/src/openhuman/util/bm25.rs new file mode 100644 index 0000000000..42c2d6e8fc --- /dev/null +++ b/src/openhuman/util/bm25.rs @@ -0,0 +1,243 @@ +//! BM25 ranking over short documents — the shared core behind `tool_search` +//! and `skill_search`. +//! +//! # Extraction-ready on purpose +//! +//! This module names **nothing** from `crate::`: no config, no `Tool`, no +//! workspace, no security policy. It takes `(id, text)` pairs and returns +//! ranked ids. That is not incidental tidiness — a capability that ends up in +//! a loadable module has to be reachable over a bus, and anything that reaches +//! back into the host cannot go. Ranking is the half of skill discovery that is +//! the same for every host; **which** bundles exist, whether the caller may +//! read one, and what a trust marker means are the half that never leaves. +//! +//! Keep it that way. A `use crate::` line here is the edit that makes the move +//! expensive, and it will look harmless at the time. +//! +//! # Why hand-rolled +//! +//! Codex uses the `bm25` crate for the same job. This crate is kernel surface +//! under a dependency-floor ratchet (`scripts/kernel-floor.limits`), and the +//! arithmetic below is a better trade than a package on the floor of every +//! embedder's build. + +use std::collections::HashMap; + +/// Words that carry no capability meaning, dropped from a query before ranking. +/// +/// A document-frequency threshold alone cannot do this job on a small corpus, +/// and the failure is not hypothetical: with three skills installed, "a" +/// appeared in exactly one description, so by frequency it was the *most* +/// distinguishing term in the query "provision a kubernetes cluster" — and the +/// changelog skill was returned as the match. The df rule handles a large +/// corpus; this list handles a small one. Both are needed. +/// +/// Deliberately short and deliberately English-only. It can only ever *remove* +/// terms, so a description in another language ranks exactly as it does today +/// rather than worse. Words that could name a capability are left out on +/// purpose — "up" stays, because "look up" is a thing a tool does. +const STOPWORDS: &[&str] = &[ + "a", "an", "and", "are", "as", "at", "be", "but", "by", "can", "do", "for", "from", "how", "i", + "if", "in", "into", "is", "it", "its", "me", "my", "of", "on", "or", "our", "so", "that", + "the", "their", "them", "then", "there", "these", "they", "this", "to", "was", "we", "were", + "what", "when", "which", "who", "will", "with", "would", "you", "your", +]; + +/// BM25 term-frequency saturation. The standard default. +pub const K1: f64 = 1.2; +/// BM25 length normalisation. The standard default. +pub const B: f64 = 0.75; + +/// Split text into search terms. +/// +/// Splits on non-alphanumerics **and** on a lower→upper transition, so +/// `memory_hybrid_search` and `readWorkflowResource` both yield the words a +/// person would actually type. Without the camelCase rule a query for +/// "workflow" misses a tool whose only mention of it is inside an identifier. +pub fn tokenize(text: &str) -> Vec { + let mut out = Vec::new(); + let mut current = String::new(); + let mut previous_lower = false; + for ch in text.chars() { + if ch.is_alphanumeric() { + if ch.is_uppercase() && previous_lower && !current.is_empty() { + out.push(std::mem::take(&mut current)); + } + current.extend(ch.to_lowercase()); + previous_lower = ch.is_lowercase() || ch.is_numeric(); + } else if !current.is_empty() { + out.push(std::mem::take(&mut current)); + previous_lower = false; + } + } + if !current.is_empty() { + out.push(current); + } + out +} + +/// A ranked corpus of short documents. +/// +/// Generic over nothing: documents are identified by their index into the slice +/// the caller built the index from, so the caller keeps its own richer type and +/// this module never has to know about it. +#[derive(Default, Debug)] +pub struct Bm25Index { + documents: Vec, + document_frequency: HashMap, + average_length: f64, +} + +#[derive(Debug, Clone)] +struct Document { + /// Used only to break score ties deterministically. + sort_key: String, + tokens: Vec, +} + +impl Bm25Index { + /// Build from `(sort_key, searchable_text)` pairs, in caller order. + /// + /// `sort_key` breaks ties; make it the id the caller would print, so two + /// identical queries produce identical output. An unstable order would make + /// a model's transcript non-reproducible for no benefit. + pub fn build<'a>(documents: impl IntoIterator) -> Self { + let documents: Vec = documents + .into_iter() + .map(|(sort_key, text)| Document { + sort_key: sort_key.to_string(), + tokens: tokenize(text), + }) + .collect(); + + let mut document_frequency: HashMap = HashMap::new(); + for doc in &documents { + let mut seen: Vec<&str> = Vec::new(); + for token in &doc.tokens { + if !seen.contains(&token.as_str()) { + seen.push(token); + *document_frequency.entry(token.clone()).or_insert(0) += 1; + } + } + } + + let total: usize = documents.iter().map(|d| d.tokens.len()).sum(); + let average_length = if documents.is_empty() { + 0.0 + } else { + total as f64 / documents.len() as f64 + }; + + Self { + documents, + document_frequency, + average_length, + } + } + + pub fn is_empty(&self) -> bool { + self.documents.is_empty() + } + + pub fn len(&self) -> usize { + self.documents.len() + } + + /// Rank against `query`, best first, returning **indices** into the corpus. + /// + /// Only documents scoring above zero are returned. Padding the list out to + /// `limit` with unrelated entries would spend exactly the tokens this + /// mechanism exists to save, and would invite the model to act on something + /// that has nothing to do with what it asked for. + pub fn search(&self, query: &str, limit: usize) -> Vec { + let terms = self.significant(tokenize(query)); + if terms.is_empty() || self.documents.is_empty() { + return Vec::new(); + } + let mut scored: Vec<(f64, usize)> = self + .documents + .iter() + .enumerate() + .map(|(index, doc)| (self.score(doc, &terms), index)) + .filter(|(score, _)| *score > 0.0) + .collect(); + + scored.sort_by(|a, b| { + b.0.partial_cmp(&a.0) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + self.documents[a.1] + .sort_key + .cmp(&self.documents[b.1].sort_key) + }) + }); + scored.into_iter().take(limit).map(|(_, i)| i).collect() + } + + /// Drop query terms that are too common in this corpus to mean anything. + /// + /// # Why this is needed, and why the obvious fix is wrong + /// + /// The IDF below carries the standard `+ 1`, which keeps a term that + /// appears in every document at a small **positive** weight rather than a + /// negative one. That is deliberate — without it, a corpus of one document + /// scores every term at zero and nothing is ever findable, which is the + /// common case here (a user with one installed skill). + /// + /// The cost is that "a" and "the" score. Measured on a three-skill corpus, + /// the query "provision a kubernetes cluster" matched a changelog skill, + /// on the strength of the word "a" alone. A model handed that result has + /// been told, with a ranking behind it, that a changelog tool provisions + /// clusters. + /// + /// Clamping negative IDF to zero — the usual textbook answer — fixes the + /// stopwords and breaks the one-document case in the same edit. So the + /// filter is a document-frequency threshold instead, with a floor of 2 that + /// makes it inert on a single-document corpus: + /// + /// `drop the term when df >= max(2, ceil(0.8 * n))` + /// + /// One skill: the threshold is 2 and no term can reach it. Three: a term in + /// all three goes. Twenty: a term in sixteen or more goes. + /// + /// The threshold alone is not enough on a small corpus — see [`STOPWORDS`], + /// which is the other half and the one that caught the real bug. + fn significant(&self, terms: Vec) -> Vec { + let n = self.documents.len(); + if n == 0 { + return Vec::new(); + } + let threshold = std::cmp::max(2, (0.8 * n as f64).ceil() as usize); + terms + .into_iter() + .filter(|term| !STOPWORDS.contains(&term.as_str())) + .filter(|term| *self.document_frequency.get(term).unwrap_or(&0) < threshold) + .collect() + } + + fn score(&self, doc: &Document, terms: &[String]) -> f64 { + let length = doc.tokens.len() as f64; + let count = self.documents.len() as f64; + terms + .iter() + .map(|term| { + let frequency = doc.tokens.iter().filter(|t| *t == term).count() as f64; + if frequency == 0.0 { + return 0.0; + } + let df = *self.document_frequency.get(term).unwrap_or(&0) as f64; + // Standard BM25 IDF with the +1 that keeps a term present in + // every document at a small positive weight rather than a + // negative one. + let idf = (((count - df + 0.5) / (df + 0.5)) + 1.0).ln(); + let denominator = + frequency + K1 * (1.0 - B + B * length / self.average_length.max(1.0)); + idf * (frequency * (K1 + 1.0)) / denominator + }) + .sum() + } +} + +#[cfg(test)] +#[path = "bm25_tests.rs"] +mod tests; diff --git a/src/openhuman/util/bm25_tests.rs b/src/openhuman/util/bm25_tests.rs new file mode 100644 index 0000000000..b347eccce2 --- /dev/null +++ b/src/openhuman/util/bm25_tests.rs @@ -0,0 +1,108 @@ +use super::*; + +#[test] +fn snake_case_and_camel_case_both_split() { + assert_eq!( + tokenize("memory_hybrid_search"), + ["memory", "hybrid", "search"] + ); + assert_eq!( + tokenize("readWorkflowResource"), + ["read", "workflow", "resource"] + ); + assert_eq!(tokenize("HTTPServer2"), ["httpserver2"]); +} + +fn corpus() -> Bm25Index { + Bm25Index::build([ + ("alpha", "send an email to a colleague"), + ("beta", "look up a stock quote by ticker symbol"), + ("gamma", "read a file from the workspace"), + ]) +} + +#[test] +fn a_plain_language_query_finds_the_right_document() { + assert_eq!(corpus().search("stock ticker", 3), vec![1]); + assert_eq!(corpus().search("email a colleague", 3)[0], 0); + assert_eq!(corpus().search("read from the workspace", 3)[0], 2); +} + +#[test] +fn nothing_relevant_returns_nothing_rather_than_padding() { + // The load-bearing property. A ranker that always returns `limit` + // results turns a miss into a confident wrong answer. + assert!(corpus().search("photosynthesis", 3).is_empty()); +} + +#[test] +fn a_query_matching_only_on_stopwords_is_a_miss() { + // The regression, and it took two attempts to fix. "a" appears in + // exactly ONE of these three documents, so by document frequency it is + // the most distinguishing term in the query — the df threshold cannot + // catch it, and the first fix that only had the threshold still ranked + // a changelog skill as the match for provisioning a cluster. + assert!(corpus() + .search("provision a kubernetes cluster", 3) + .is_empty()); + assert!(corpus().search("a", 3).is_empty()); + assert!(corpus() + .search("what is it that you will do for me", 3) + .is_empty()); +} + +#[test] +fn a_single_document_corpus_stays_searchable() { + // The reason the threshold has a floor of 2 rather than being a plain + // ratio. With one document every term is in every document, so a ratio + // rule would filter the entire query and make the only installed skill + // permanently unfindable. + let one = Bm25Index::build([("solo", "post a message to discord")]); + assert_eq!(one.search("discord", 1), vec![0]); + assert_eq!(one.search("post a message", 1), vec![0]); +} + +#[test] +fn a_real_term_still_matches_even_alongside_stopwords() { + // The filter must drop the noise, not the query. + assert_eq!(corpus().search("look up a stock", 3), vec![1]); +} + +#[test] +fn an_empty_query_and_an_empty_index_are_both_safe() { + assert!(corpus().search("", 3).is_empty()); + assert!(Bm25Index::build([]).search("anything", 3).is_empty()); +} + +#[test] +fn ranking_is_stable_across_identical_queries() { + let index = corpus(); + let first = index.search("a", 3); + for _ in 0..5 { + assert_eq!(index.search("a", 3), first); + } +} + +#[test] +fn ties_break_on_the_sort_key_not_on_insertion_order() { + // Two documents with identical text score identically; the sort key + // decides. Built in reverse order so insertion order would give the + // opposite answer. + // Three documents, two identical: with only two, every term would be + // in every document and `significant` would filter the query away. + let index = Bm25Index::build([ + ("zulu", "same words here"), + ("alpha", "same words here"), + ("other", "entirely different text"), + ]); + assert_eq!(index.search("same words", 2), vec![1, 0]); +} + +#[test] +fn the_limit_is_honoured() { + // "read email stock" hits all three documents on a real term each, so + // the unlimited search returns three — which is what makes this a test + // of the cap rather than a query that happened to match once. + assert_eq!(corpus().search("read email stock", 10).len(), 3); + assert_eq!(corpus().search("read email stock", 1).len(), 1); +} diff --git a/src/openhuman/util/mod.rs b/src/openhuman/util/mod.rs index f7ca7d4646..6ca55ff660 100644 --- a/src/openhuman/util/mod.rs +++ b/src/openhuman/util/mod.rs @@ -7,6 +7,7 @@ //! - [`retry`] — retry-with-backoff + transient-filesystem-error classification //! - [`sanitize`] — LLM-facing text sanitization (control-char stripping, //! instruction-fence removal, UTF-8-safe byte caps) +//! - [`bm25`] — BM25 ranking over short documents (tool + skill search) //! - [`tls`] — TLS client/connector construction //! - [`types`] — shared utility types //! @@ -14,6 +15,9 @@ //! `openhuman::util::` paths (including the `truncate_with_ellipsis` //! doctest) still resolve. +/// BM25 ranking over short documents, shared by `tool_search` and +/// `skill_search`. Deliberately names nothing from `crate::` — see its docs. +pub mod bm25; /// PII redaction for log output. See the module docs for why this is here and /// not taken from the memory engine. pub mod redact; diff --git a/vendor/tinyagents b/vendor/tinyagents index 3536708941..0eb7a81ea2 160000 --- a/vendor/tinyagents +++ b/vendor/tinyagents @@ -1 +1 @@ -Subproject commit 35367089414bad1c1a38ea64218121f7d3a9a61b +Subproject commit 0eb7a81ea2d43f5f56d1d3f3640e4a8d08956ddd