Skip to content

fix: let bare Tools compete directly in delegate selection (not just as a last resort) - #195

Open
imaustink wants to merge 6 commits into
mainfrom
fix/ssh-skill-routing
Open

fix: let bare Tools compete directly in delegate selection (not just as a last resort)#195
imaustink wants to merge 6 commits into
mainfrom
fix/ssh-skill-routing

Conversation

@imaustink

@imaustink imaustink commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Summary

Follow-up to #192. After that PR deployed, "Can you SSH into airvinyl and see if it's running all good?" in Open WebUI got a generic LLM fallback with no tool use, and a follow-up with a specific command ("SSH into airvinyl and run uptime") got stuck showing "found 1 agent candidate" indefinitely.

Root cause, confirmed against the live cluster: claude-code-swe-agent is the only Agent in the whole catalog, and its broad description ("Performs software-engineering work on GitHub end-to-end. Runs the Claude Code CLI headless — which has bash, file-read/write, grep, and glob tools...") loosely matched via embedding similarity. selectDelegate only ever considered a bare Tool (like ssh, with no wrapping Skill) as a last resort — reachable only once skillCandidates AND agentCandidates both came up empty. Since an Agent candidate always existed, the ssh Tool never got a chance to compete at all, and dispatching to the agent hung on its Claude identity-link gate (a known, pre-existing bug class — fix/claude-auth-submit-hang et al. — unrelated to #192's code).

Fix

1. The core root cause (docs/adr/0037-tools-compete-directly-in-delegate-selection.md) — a bare Tool was starved out categorically, not because it lost a comparison but because it was never in the comparison. Any future unwrapped Tool would hit the identical problem the moment a broad Agent (or Skill) exists in the catalog. Fixed in both execution engines:

langgraph engine (apps/agent-orchestrator)

  • A new retrieveTools node (agent/graph.ts) inserted between retrieveAgents and selectDelegate: runs the same embedding query + ToolFitChecker two-stage relevance gate selectFallbackTool already used. Guarded on deps.delegateSelector being configured, so non-NATS deployments pay no extra cost and keep the exact old behavior.
  • DelegateSelector.select (agent/delegate-selector.ts) takes a third tools parameter and makes one combined three-way choice (skill/agent/tool), with an explicit preference order: skill (authored guidance) > bare tool (single well-defined action) > agent (open-ended/multi-step work).
  • selectDelegate's tool branch reuses a new shared helper (planFallbackToolCall, extracted from selectFallbackTool's tail) to construct the actual call, and treats it as a first-class selection (no self-improvement footer), falling back to noMatchFallback/selectFallbackTool only if the planner declines or the combined choice comes back empty.

Temporal engine (engines/temporal) — added in this update, in response to @imaustink's review question. The Go engine had the identical asymmetry:

  • SelectDelegate (activities/delegate.go) gains a Tools input and a "tool" DelegateChoice with the same skill > tool > agent preference order, validating the chosen id against the offered set.
  • runAgentTurn (workflows/agentloop.go) fit-checks catalog tools (reusing fitCandidates + retrieveCatalogTools) and offers survivors to the combined selector. A "tool" choice runs first-class via runSelectedTool (meta.Path tool, no self-improvement footer); a decline falls through to noMatchFallback.
  • fallback.go refactored: shared planToolCall + footer-parameterised runToolCall.

2. ssh-skill (charts/community-components/templates/skill-ssh.yaml) — kept alongside the core fix (it still adds authored multi-step diagnostic guidance a bare Tool alone can't). Its markdown is now derived from .Values.sshTool rather than hardcoded, so it can't drift from the ssh Tool on non-author deployments: the write-capability section is gated on allowedCommands == "*" (read-only guidance otherwise), and the target list is rendered from sshConfig/allowedHosts.

Review feedback addressed

  • wasFallback: true on the tool branch (graph.ts) — removed; a deliberately-selected tool is a first-class match and must not append the "nothing matched" footer. Regression assertion added.
  • skill markdown hardcoded deployment facts (skill-ssh.yaml) — now derived from .Values.sshTool (see above).
  • "same fix for the Temporal side?" — yes; ported (see Temporal engine above).

ADR renumbered 0036 → 0037 after merging main, which took 0036 for the Temporal execution engine.

Test plan

  • npm run typecheck/build/test --workspace=agent-orchestrator595/595 passing (incl. the wasFallback regression assertion)
  • Temporal engine: gofmt -l clean, go build ./..., go vet ./..., go test ./... — all pass, incl. 3 new tests (3-way SelectDelegate validation; a tool winning over an agent first-class with the agent never launched and no footer; a fit-gate-rejected tool excluded from the selector)
  • helm lint + helm template against values.yaml, values-production.yaml, values-ci-all.yaml; all 33 community-components templates render and the output parses; rendered ssh-skill markdown matches each deployment's configured commands + hosts
  • Real chat retest against the live cluster once deployed ("is airvinyl running okay", "SSH into airvinyl and run uptime")

🤖 Generated with Claude Code

https://claude.ai/code/session_01RDDEgbKjLxXbavFPsJFyWk

imaustink and others added 2 commits August 4, 2026 16:01
"Is airvinyl running okay" matched claude-code-swe-agent -- the only
Agent in the catalog, description broad enough ("runs bash...") to
loosely overlap via embedding similarity -- instead of the ssh tool,
and hung on that agent's Claude identity-link gate (a known class of
pre-existing bug in this repo: fix/claude-auth-submit-hang et al.).
Nothing about that path is specific to ssh; any bare Tool with no
wrapping Skill is exposed to the same mis-routing risk once a broad
Agent exists in the catalog.

Adds ssh-skill (mirroring cluster-debug-skill's shape: toolRefs: [ssh],
no allowedRoles of its own per ADR 0011), so selectDelegate's combined
skill+agent choice sees a strong, specific match for SSH-shaped
requests and picks it over the vague agent match. Its markdown also
gives authored guidance for turning an open-ended "is it healthy?"
into a concrete sequence of read-only diagnostic calls (uptime, df -h,
free -m, then a named service if implied) -- separately addressing
why a vague request was declining at the ActionPlanner stage even when
the ssh tool WAS the right fallback candidate.

Verified with a real kind cluster + kubectl apply --dry-run=server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fg8b9pPWm91nLbDnB6ECJh
…tion

Fixes the actual root cause behind ssh-skill being needed at all: a
bare Tool with no wrapping Skill was only ever considered as a last
resort (noMatchFallback's selectFallbackTool), invoked only once
skillCandidates AND agentCandidates both came up empty. Any Agent
whose description loosely overlapped a request via embedding
similarity alone -- not because it was actually the better fit, but
because a Tool never got the chance to compete at all -- would win by
existing as a candidate. That's what let claude-code-swe-agent (the
only Agent in the catalog) absorb "SSH into X" requests instead of
the ssh Tool, hanging on its identity-link gate.

Adds a `retrieveTools` graph node (embedding query + ToolFitChecker,
reusing selectFallbackTool's own two-stage relevance gate) alongside
retrieveSkills/retrieveAgents, and extends DelegateSelector to a
three-way choice among skills/agents/tools in one combined decision
(docs/adr/0036). Guarded on deps.delegateSelector being configured, so
non-NATS deployments pay no extra cost and keep the exact old
skill-only + selectFallbackTool behavior.

ssh-skill (this branch's other commit) stays -- it still adds value
a bare Tool alone can't: authored guidance for turning an open-ended
"is it healthy?" into a concrete sequence of diagnostic calls. This
fixes the starvation itself so no *future* unwrapped Tool needs a
Skill just to be reachable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fg8b9pPWm91nLbDnB6ECJh
@imaustink imaustink changed the title fix: add ssh-skill so SSH requests route to the tool, not an agent fix: let bare Tools compete directly in delegate selection (not just as a last resort) Aug 4, 2026
@k5s-bot

k5s-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

🤖 Starting work on this now.

@k5s-bot

k5s-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

🤖 Watch live or take over the session here: https://claude.ai/code/session_01Bn6S7LNrrgd7nfGXtVrddZ

@k5s-bot

k5s-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

Something went wrong processing this: /invoke/96006fd2-e3e7-4e21-a8af-75f0de005d07 poll failed: 404

@k5s-bot k5s-bot Bot removed the ai-review label Aug 18, 2026

@k5s-bot k5s-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, well-scoped fix — retrieveTools correctly reuses the existing embedding query + ToolFitChecker gate, the three-way selector change is clean, and the tests cover the important routing cases. Two issues before merge: one user-facing correctness bug on the new tool branch, and one drift risk in the un-templated skill markdown. (No CI checks are configured on this branch, so nothing was running to gate this review.)

Comment thread apps/agent-orchestrator/src/agent/graph.ts
Comment thread charts/community-components/templates/skill-ssh.yaml
@imaustink

Copy link
Copy Markdown
Owner Author

We probably need to implement the same fix for the Temporal side, right?

@k5s-bot

k5s-bot Bot commented Aug 23, 2026

Copy link
Copy Markdown

🤖 Starting work on this now.

@k5s-bot

k5s-bot Bot commented Aug 23, 2026

Copy link
Copy Markdown

🤖 Watch live or take over the session here: https://claude.ai/code/session_01RDDEgbKjLxXbavFPsJFyWk

… values

- graph.ts: a tool deliberately picked by delegateSelector in the three-way
  comparison is a first-class match, not a fallback. Drop the erroneous
  `wasFallback: true` that appended the self-improvement ("nothing matched")
  footer to every request this branch successfully routed. Genuine no-matches
  still flow through noMatchFallback, which sets the flag correctly.
- graph.test.ts: assert wasFallback stays false when a tool is chosen.
- skill-ssh.yaml: derive the skill markdown from .Values.sshTool (the same
  source the ssh Tool renders from) instead of hardcoding author-specific
  facts. Gate the write-capability claim on allowedCommands=="*" (read-only
  guidance otherwise) and render the target list from sshConfig/allowedHosts,
  so the skill can't drift from the Tool on non-author deployments.
Ports ADR 0037 to the Temporal execution engine, which had the identical
asymmetry the langgraph engine did: runAgentTurn retrieved only skills and
agents, SelectDelegate chose skill-vs-agent, and a bare Tool was reachable
only via noMatchFallback's selectFallbackTool — so a broad Agent pre-empted a
better-fitting Tool there too (the maintainer flagged this on #195).

- activities/delegate.go: SelectDelegate gains a Tools input and a "tool"
  DelegateChoice, with a skill > tool > agent preference order in its prompt;
  a chosen tool id is validated against the offered set (hallucinations fail
  to no-match, like SelectSkill).
- workflows/agentloop.go: fit-check catalog tools (reusing fitCandidates +
  retrieveCatalogTools) between agent retrieval and selection, and offer the
  survivors to the combined selector when agents OR tools exist. A "tool"
  choice runs first-class via runSelectedTool (meta.Path "tool", NO
  self-improvement footer); a planner decline or empty choice falls through
  to noMatchFallback's safety net.
- workflows/fallback.go: extract shared planToolCall (from selectFallbackTool's
  tail) and runToolCall (footer-parameterised core of runFallbackTool);
  runSelectedTool is the footer-less first-class variant.
- Tests: activity-level 3-way SelectDelegate validation, plus workflow tests
  for a tool winning over an agent (first-class, agent never launched, no
  footer) and a fit-gate-rejected tool being excluded from the selector.

Docs: extend ADR 0037 with a "Temporal engine" section describing the port.
@k5s-bot

k5s-bot Bot commented Aug 23, 2026

Copy link
Copy Markdown

@imaustink yes — you're right, the Temporal engine had the identical asymmetry, and I've ported the same fix in 098957f.

runAgentTurn (engines/temporal/internal/temporal/workflows/agentloop.go) retrieved only skills and agents, SelectDelegate chose skill-vs-agent, and a bare Tool was reachable only through noMatchFallback's selectFallbackTool — so a broad Agent pre-empted a better-fitting Tool there too. Now:

  • SelectDelegate (activities/delegate.go) takes a Tools input and a "tool" DelegateChoice, with the same skill > tool > agent preference order, and validates the chosen id against the offered set (hallucinations fail to no-match, like SelectSkill).
  • runAgentTurn fit-checks catalog tools (reusing fitCandidates + retrieveCatalogTools) between agent retrieval and selection and offers the survivors to the combined selector when agents or tools exist.
  • A "tool" choice runs first-class via runSelectedTool (meta.Path tool, no self-improvement footer); a planner decline or empty choice falls through to noMatchFallback's safety net.

gofmt/go build/go vet/go test ./... all pass, including a 3-way SelectDelegate validation test and workflow tests for a tool winning over an agent (agent episode never starts, no footer) and a fit-gate-rejected tool being excluded from the selector. ADR 0037 gained a "Temporal engine" section describing the port.

@k5s-bot

k5s-bot Bot commented Aug 23, 2026

Copy link
Copy Markdown

Picked this PR back up — summary of changes

Review feedback addressed

  1. wasFallback: true on selectDelegate's tool branch (graph.ts) — removed (8b433f6). A tool the three-way selector deliberately picks is a first-class match, exactly like the agent/skill branches, so it no longer appends the SELF_IMPROVEMENT_FOOTER ("nothing matched…") to every SSH-shaped request it successfully routes. Genuine no-matches still flow through noMatchFallback, which sets the flag correctly. Added a regression assertion (wasFallback stays false). Thread resolved.

  2. skill-ssh.yaml hardcoded deployment-specific facts (8b433f6) — the skill markdown is now derived from the same .Values.sshTool the ssh Tool renders from: the write-capability section is gated on allowedCommands == "*" (a ## Read-only section that tells the model it can't write is rendered otherwise), and the target list comes from sshConfig/allowedHosts instead of the baked-in host list. Verified via helm template against all three values files. Thread resolved.

  3. "same fix for the Temporal side?" (@imaustink) — yes; ported to engines/temporal in 098957f (details in the reply above and in the PR description).

Branch sync

Merged origin/main into the branch (this repo uses merge commits for PR history). One conflict, in docs/adr/README.md: main had claimed ADR 0036 for the Temporal execution engine, so I renumbered this PR's ADR 0036 → 0037 (file + all references) and kept both entries. graph.ts/graph.test.ts auto-merged cleanly.

Verification

  • agent-orchestrator: typecheck + build + test595/595 pass.
  • engines/temporal: gofmt -l clean, go build/go vet/go test ./... all pass (3 new tests).
  • helm lint + helm template (values.yaml / values-production.yaml / values-ci-all.yaml): all 33 community-components templates render and the output parses.

Deliberately not done

  • The live-cluster chat retest (last item on the test plan) still needs a real deploy — I can't exercise the cluster from here, so it remains unchecked.
  • No CI checks are configured on this branch, so nothing external gated this run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants