merge inc2/main: one line again
CI/CD / containment (push) Successful in 1m55s
Hanzo CI/CD / cicd (push) Failing after 6m48s
CI/CD / gate (push) Failing after 6m49s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
CI/CD / containment (push) Successful in 1m55s
Hanzo CI/CD / cicd (push) Failing after 6m48s
CI/CD / gate (push) Failing after 6m49s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
The two private lines had diverged 191/18 off a merge-base six hours old. Production builds from inc2; every push went to forge; neither knew. The 14 conflicting hunks, and why each resolved the way it did: - bridgeReply loses its ctx parameter (inc2). Both lines fixed the same production bug — the webhook's ctx made zip drop the stated org, so the balance gate answered "no org on the call". forge passed a detached ctx in; inc2 deleted the parameter so the webhook's cannot be passed at all, and states the tenant with cloud.For. Unrepresentable beats discouraged. Its body now calls forge's bridgeIdentity, so the link lookup exists once. - commerce prefixes are the union: forge's /v1/cart and the /v1/billing/topup stem, inc2's accounts, credit-balance and transactions. topup/token is dropped — the stem owns its subtree. A prefix missing here never reaches commerce; it falls to ai's bare /v1 and answers ai's 404. - zipdoc-check keeps inc2's target (forge repeated the loop twice) with forge's body: per-package, because whole-module load extracts differently than the generator it polices, and skipping dot-dirs, because an agent worktree is a second checkout and the walk read 203 packages where there are 104. - plane.go keeps both new sections — sandbox ops and the coding-run seams are orthogonal, no shared names. - plane_debit_harness_test.go takes forge's, which delegates to internal/planetest instead of holding a second copy. Same API. The copy is what hid a bind failure: a unix path is 108 bytes and t.TempDir() spells the test's name into it. - metering keeps Actor and the token counts; plane.Usage has the fields, and a debit without them can be re-read but not re-derived. - o11y v1.5.62 over v1.5.61 — upgrade, never downgrade. go build ./... and go vet clean. Full suite: 3 red (base, code, commerce), identical to both parents — this box's SQLite lacks acos and fts5.
This commit is contained in:
@@ -598,9 +598,24 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
provenance: false
|
||||
tags: ghcr.io/hanzoai/cloud:v${{ steps.ver.outputs.version }}
|
||||
# `outputs:` in place of `push: true` — the only way to reach buildkit's
|
||||
# exporter attributes through this action, and `push: true` cannot be set
|
||||
# alongside it because both ask for an image exporter.
|
||||
#
|
||||
# zstd because this image is ONE 1.63GB layer (the per-app plugin
|
||||
# binaries, 98.8% of it) and gzip writes a layer as a single stream on a
|
||||
# single core: 175.0s to export, seven of eight CPUs idle. Measured on the
|
||||
# same bytes, both orderings, 2026-08-06: gzip 245.5s/256.1s against zstd
|
||||
# 58.1s/53.3s, and the zstd image is 1.2% smaller.
|
||||
#
|
||||
# This lane matches apps/platform/k8s.go deliberately. The two build the
|
||||
# SAME Dockerfile into the SAME repository, and a compression setting that
|
||||
# differs between them is not a preference — it decides the manifest media
|
||||
# type, which decides whether imagePullable's Accept negotiation gets a
|
||||
# 200 or a 404 (see the note in apps/platform/pin.go).
|
||||
outputs: type=image,push=true,compression=zstd,force-compression=true,oci-mediatypes=true
|
||||
# VERSION is what the binary reports as X-Api-Version. Without it the
|
||||
# ldflag falls back to the `dev` default and a released image cannot
|
||||
# say which release it is — and every car below keys off that header.
|
||||
|
||||
+44
-15
@@ -213,19 +213,31 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
|
||||
SQLITE_REQUIRE_CODEC=1 CGO_ENABLED=1 go test -count=1 -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" \
|
||||
-run 'TestEncryptionProof|TestUnwrapGoldenFixture|TestWrapUnwrapRoundTripPinsLayout' \
|
||||
github.com/hanzoai/sqlite
|
||||
# Go drops comments at compile time, so this pass is the ONLY way a typed handler's
|
||||
# prose reaches the document: zipdoc lifts it into zipdoc_gen.go, which registers it
|
||||
# with zip.Describe at init. It must run BEFORE every build below, because the
|
||||
# generated file is compiled INTO each binary — running it after would be too late.
|
||||
# NO `go generate -run zipdoc` HERE, DELIBERATELY — and the reason is not that the
|
||||
# lifted prose stopped mattering. It still is the only way a typed handler's words
|
||||
# reach /v1/openapi.json: Go drops comments at compile time, zipdoc lifts them into
|
||||
# zipdoc_gen.go, and that file is compiled INTO each binary below. An image whose
|
||||
# binaries lack it serves the 1441 description-less operations this step was added
|
||||
# to fix, and the SDK repos and the CLI read that document.
|
||||
#
|
||||
# mk/plugin.mk makes this a prerequisite of the per-app `build`, so the per-app path
|
||||
# has always had it. This path did not, and the omission is measurable in production:
|
||||
# api.hanzo.ai/v1/openapi.json serves 1441 operations with ZERO descriptions, which
|
||||
# is exactly the binary mk/plugin.mk warns about. The SDK repos and the CLI read that
|
||||
# document, so the prose never reached any of them either.
|
||||
RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
|
||||
--mount=type=cache,id=cloud-gobuild-v4,target=/root/.cache/go-build,sharing=locked \
|
||||
go generate -run zipdoc ./...
|
||||
# The fix for that was never "regenerate during the build". All 99 zipdoc_gen.go
|
||||
# files are COMMITTED — they are source, the way generated Go is source everywhere
|
||||
# else — so the tree `COPY . .` just brought in already contains them, and every
|
||||
# `go build` below compiles the real prose in whether or not anything regenerates.
|
||||
# Running the generator here re-derived those 99 files from the same inputs to
|
||||
# produce the same bytes, for 355.9s of a 17-minute build: 35% of the wall clock
|
||||
# spent proving a file equals itself.
|
||||
#
|
||||
# Freshness is the real requirement, and it is a property of the COMMIT, not of the
|
||||
# image. So it is enforced where commits are: `make zipdoc-check` regenerates from
|
||||
# source and fails on any diff (hanzo.yml, step `zipdoc-current`), which runs in
|
||||
# the test lane every later job already declares `needs:` on. A stale lift now
|
||||
# cannot be merged — which is strictly stronger than this step, because this step
|
||||
# would happily build a correct image from a stale commit and leave main wrong.
|
||||
# That is not hypothetical: main carried a stale apps/agents lift while this ran.
|
||||
#
|
||||
# It must stay out. Re-adding it buys nothing a green `zipdoc-current` has not
|
||||
# already proven, and costs the 355.9s back.
|
||||
# The commit this image is built FROM, handed in by the SAME builder that already
|
||||
# feeds it to the OCI label in the final stage (apps/platform buildFrontendCmdRev,
|
||||
# `--opt build-arg:REVISION=<sha>`; the other lane passes github.sha).
|
||||
@@ -301,6 +313,20 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
|
||||
# one-line manifest edit and this Dockerfile does not change. An app with no
|
||||
# plugin/<app> fails HERE (the generator's bijection would have caught it first).
|
||||
#
|
||||
# EXCEPT the CORESIDENT ones, which get no binary. Coresident means the app is not
|
||||
# prefix-routed: it mounts as middleware on a sibling's router, and cmd/cloud's
|
||||
# mount() returns before it can ever resolve a path or spawn a child. So its binary
|
||||
# is linked, copied and pulled on every deploy to be executed never. zen is the one:
|
||||
# 164.7 MB, 3.9% of this image, for a process that cannot start. Its behaviour ships
|
||||
# in /ai, which links apps/zen and mounts the Claim ahead of ai's catch-all.
|
||||
#
|
||||
# TWO lists, because they answer two questions. `names` is every manifest app and
|
||||
# still guards the bijection above — a coresident app must STILL have a plugin/<app>
|
||||
# (gen-app-cmds requires it, and it is what runs standalone in dev). `spawned` is
|
||||
# what the host can actually load, and that is what earns a binary. Flip
|
||||
# Coresident:false in the manifest and the binary comes back on the next build,
|
||||
# because both lists read the same source the host does.
|
||||
#
|
||||
# Each link is the ONE app's own graph (~600–2200 packages), NEVER the ~3040-pkg
|
||||
# fleet union the fused binary was. 112 lean links, sequential, none of them mega —
|
||||
# which is the whole point of this change.
|
||||
@@ -332,9 +358,12 @@ RUN --mount=type=cache,id=cloud-gomod-v4,target=/go/pkg/mod,sharing=locked \
|
||||
[ -n "$names" ] || { echo "FATAL: no apps parsed from manifest/apps.go — the derivation broke, not the app list"; exit 1; }; \
|
||||
for p in $names; do \
|
||||
[ -d "./plugin/$p" ] || { echo "FATAL: manifest app '$p' has no plugin/$p — run 'make generate' and commit"; exit 1; }; \
|
||||
echo "building plugin $p"; \
|
||||
CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" -ldflags="$GO_LDFLAGS" -o "/plugins/$p" "./plugin/$p"; \
|
||||
done
|
||||
done; \
|
||||
coresident="$(sed -n '/Coresident: *true/{s/.*{Name: "\([^"]*\)".*/\1/p;}' manifest/apps.go)"; \
|
||||
spawned="$(sed -n '/Coresident: *true/d; s/.*{Name: "\([^"]*\)".*/\1/p' manifest/apps.go)"; \
|
||||
echo "building $(echo "$spawned" | wc -w) of $(echo "$names" | wc -w) plugins, $(nproc) at a time (coresident, never spawned: ${coresident:-none})"; \
|
||||
printf '%s\n' $spawned | xargs -P "$(nproc)" -I{} sh -c \
|
||||
'CGO_ENABLED=1 go build -tags "libsqlite3 sqlite_fts5 sqlite_math_functions" -ldflags="$GO_LDFLAGS" -o "/plugins/$1" "./plugin/$1" || { echo "FATAL: plugin $1 failed to build" >&2; exit 255; }' _ {}
|
||||
# THE STAMP LANDED — asked of the ARTIFACT, not of the flag string.
|
||||
#
|
||||
# `-X` naming a path or symbol the linker cannot resolve is not an error: it is
|
||||
|
||||
@@ -2887,6 +2887,26 @@ semantic is identical — fail closed once armed, allow before.
|
||||
because a silently-short list and a stale file are the same defect. A `tools/call`
|
||||
goes to the app that listed the name, verbatim; a name nobody has listed costs one
|
||||
discovery, then `-32602`.
|
||||
**The door publishes ONE TOOL PER SUBSYSTEM, not one per operation** (`fleet/grouped.go`).
|
||||
Measured on the deployed door: the flat projection was **1,189 tools in 977,636
|
||||
bytes** — ~244k tokens to merely enumerate what can be called — and MCP clients
|
||||
truncate (Slack keeps 128), so 1,061 operations were unreachable no matter how
|
||||
they were ordered. Ordering (`rank`) fixes which tools a truncating client keeps;
|
||||
it cannot fix a hard cap. So the surface is `hanzo_<app>` carrying
|
||||
`{"op":"<operation>","input":{…}}`, whose `op` enum holds NAMES ONLY, plus
|
||||
`hanzo_describe` — which returns one operation's own descriptor, so a model
|
||||
searches the enum and fetches the schema for the one it picked. The whole
|
||||
corpus is **116 tools in 106,847 bytes** (`fleet.TestTheWholeFleetFitsInAModelsHead`,
|
||||
which builds it from `plugin/*/openapi.json`) — 17× less per operation, for 1.9×
|
||||
MORE operations than the baseline carried. `hanzo_describe` is FIRST because it is
|
||||
what makes every other tool usable, so truncation must never take it. The envelope
|
||||
is a DECODING and not a second route: it yields the (name, message) a direct call
|
||||
carries, and `refuse()` in `gather` remains the only gate, so a refused name is in
|
||||
no enum, dispatchable through no envelope, and describable by nothing.
|
||||
**Headroom: 12 subsystems.** 116 of the 128 a client keeps. The manifest is 119
|
||||
apps and growing, so the next dozen subsystems put the door back over the cap; the
|
||||
move then is to group by product surface (`productStems`, 17 buckets), not to add
|
||||
a second projection.
|
||||
It used to read a BUILD-TIME catalogue — `plugin/<app>/mcp.json`, embedded by
|
||||
`plugin/embed.go` and handed to zip as `Plugin.Tools` — and `tools/list` was a
|
||||
memcpy. **Those 116 files are deleted (49,865 lines).** They were a second source
|
||||
|
||||
@@ -90,7 +90,7 @@ APP_BINS := $(addprefix bin/,$(APPS))
|
||||
# gate (check) sat behind a door with no handle.
|
||||
include mk/fleet.mk
|
||||
|
||||
.PHONY: help deploy-ui skills build cloud hanzo ship apps $(APP_BINS) plugin generate describe run dev smoke test test-fast test-cgo test-codec vet lint tidy docker docker-push compose clean e2e
|
||||
.PHONY: help deploy-ui skills build cloud hanzo ship apps $(APP_BINS) plugin generate describe run dev smoke zipdoc-check test test-fast test-cgo test-codec vet lint tidy docker docker-push compose clean e2e
|
||||
|
||||
help: ## Show this help.
|
||||
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z0-9_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
@@ -262,10 +262,25 @@ TEST_ENV = CLOUD_KMS_MASTER_KEY_REF="$${CLOUD_KMS_MASTER_KEY_REF:-$(DEV_KMS_KEY)
|
||||
# the shipped build carries, so the suite exercises the same schema surface.
|
||||
TEST_TAGS := sqlite_fts5
|
||||
|
||||
test: ## Run unit + integration tests (pure-Go, with the FTS5 tag the image ships).
|
||||
# The lifted prose is COMMITTED (zipdoc_gen.go) because bare `go build` cannot
|
||||
# regenerate it; -check writes nothing and goes red when a lift no longer
|
||||
# matches its source, which is the drift being committed makes possible.
|
||||
# Go drops comments at compile time, so cmd/zipdoc is the ONLY path from a typed
|
||||
# handler's prose to /v1/openapi.json — the document the SDK repos and the CLI
|
||||
# read. Its output is COMMITTED, and that is what lets a bare `go build` (and the
|
||||
# release image) produce a binary that still describes itself without anyone
|
||||
# paying to lift the prose again. The image used to pay: `go generate -run zipdoc
|
||||
# ./...` ran on the build's critical path for 355.9s of a 17-minute build, to
|
||||
# reproduce 99 files that were already in the tree.
|
||||
#
|
||||
# Committed means it can go STALE, so exactly one thing has to stay true:
|
||||
# regenerating from source changes nothing. This asserts it by running THE
|
||||
# GENERATOR and diffing, rather than asking a -check mode for a second opinion —
|
||||
# a gate must never be able to disagree with the tool it polices. It is also the
|
||||
# only form that catches the case below.
|
||||
#
|
||||
# `git status --porcelain`, not `git diff`: a NEW package's zipdoc_gen.go is
|
||||
# untracked and therefore invisible to a diff, which is the failure that matters
|
||||
# most. The pathspec scopes it to the generator's own files, so an unrelated
|
||||
# dirty tree neither hides a stale lift nor invents one.
|
||||
zipdoc-check: ## Regenerate the lifted prose FROM SOURCE and fail on any diff.
|
||||
# Per PACKAGE, not ./...: the checker must load exactly the way `go generate`
|
||||
# does, one package at a time — whole-module loading extracts differently
|
||||
# (zap-proto/zip zipdoc: single-vs-module load divergence) and a gate must
|
||||
@@ -273,9 +288,13 @@ test: ## Run unit + integration tests (pure-Go, with the FTS5 tag the image ship
|
||||
# A dot-directory is not this module's source. An agent worktree at
|
||||
# .claude/worktrees/<id>/ is a whole second checkout of this repository, and
|
||||
# the walk read it: 203 packages where there are 104, and it went red on a
|
||||
# copy's o11y while nothing here had changed. Same rule the source-walking
|
||||
# gates in Go state (typed_request_gate_test.go, orgns_test.go).
|
||||
@set -e; for d in $$(grep -rl '^//go:generate go run github.com/zap-proto/zip/cmd/zipdoc' --include='*.go' --exclude-dir='.?*' clients cmd . 2>/dev/null | xargs -n1 dirname | sort -u); do (cd $$d && $(GO) run github.com/zap-proto/zip/cmd/zipdoc -check) || { echo "$$d/zipdoc_gen.go is stale — run: go generate -run zipdoc ./$$d/..."; exit 1; }; done
|
||||
# copy's o11y while nothing here had changed.
|
||||
@set -e; for d in $$(grep -rl '^//go:generate go run github.com/zap-proto/zip/cmd/zipdoc' --include='*.go' --exclude-dir='.?*' clients cmd . 2>/dev/null | xargs -n1 dirname | sort -u); do \
|
||||
(cd $$d && $(GO) run github.com/zap-proto/zip/cmd/zipdoc -check) || { echo "$$d/zipdoc_gen.go is stale — run: go generate -run zipdoc ./$$d/..."; exit 1; }; \
|
||||
done
|
||||
|
||||
test: ## Run unit + integration tests (pure-Go, with the FTS5 tag the image ships).
|
||||
$(MAKE) zipdoc-check
|
||||
$(TEST_ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) test -tags "$(TEST_TAGS)" ./...
|
||||
# The drift gate: regenerate the document FROM SOURCE and fail on any diff.
|
||||
# The weave above proves the subsets compose; this proves they are still the
|
||||
@@ -293,9 +312,7 @@ test-fast: ## Everything `test` runs except the spec drift gate. Inner loop only
|
||||
@echo ">> test-fast: NOT checking spec drift (openapi.yaml + plugin/*/openapi.json)."
|
||||
@echo ">> a route added without regenerating will pass here and fail CI."
|
||||
@echo ">> the real gate: make -f mk/fleet.mk check"
|
||||
@set -e; for d in $$(grep -rl '^//go:generate go run github.com/zap-proto/zip/cmd/zipdoc' --include='*.go' --exclude-dir='.?*' clients cmd . 2>/dev/null | xargs -n1 dirname | sort -u); do \
|
||||
(cd $$d && $(GO) run github.com/zap-proto/zip/cmd/zipdoc -check) || { echo "$$d/zipdoc_gen.go is stale — run: go generate -run zipdoc ./$$d/..."; exit 1; }; \
|
||||
done
|
||||
$(MAKE) zipdoc-check
|
||||
$(TEST_ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) test -tags "$(TEST_TAGS)" ./...
|
||||
|
||||
# THE spec, in three steps, in the only order they work in:
|
||||
|
||||
+77
-53
@@ -1,38 +1,73 @@
|
||||
package cloud
|
||||
|
||||
// Inference reached over the peer's own socket.
|
||||
// Where a sibling process reaches the model API.
|
||||
//
|
||||
// `ai` is a plugin of this same binary running as its own process. Its routes
|
||||
// ride its unix socket exactly as they ride a public listener — zip's plane is
|
||||
// "an ordinary route on the app … ZAP over a unix socket is simply the address
|
||||
// the caller dialed" — so a sibling speaks the ordinary OpenAI-compatible wire
|
||||
// to it WITHOUT leaving the host.
|
||||
// `ai` is a plugin of this same binary running as its own process, and the
|
||||
// question this file answers is the narrow one: from ANOTHER process of the same
|
||||
// fleet, what address serves /v1/chat/completions?
|
||||
//
|
||||
// What that deletes is the whole reason the old path existed:
|
||||
// # What the plane socket is, and what it is not
|
||||
//
|
||||
// base_url https://api.hanzo.ai/v1 the pod's OWN public address
|
||||
// token_url http://iam.hanzo.svc/… a token minted to authenticate to itself
|
||||
// This used to dial the peer's canonical socket — zip.SocketPath("ai"),
|
||||
// /var/lib/cloud/run/ai.sock — and speak ordinary HTTP to it, on the belief that
|
||||
// "an app's routes ride its unix socket exactly as they ride a public listener".
|
||||
// That belief is wrong twice, and each half is independently fatal.
|
||||
//
|
||||
// Both were consequences of addressing a peer by URL. There is no address to
|
||||
// configure here: the socket is derived from the app NAME, the same mapping the
|
||||
// meter and the ledger already use.
|
||||
// THE WIRE IS NOT HTTP. That socket is served by zaphttp.Server (zip
|
||||
// transport.go: the "zap" scheme, the default for a bare address) — a framed
|
||||
// binary protocol with its own codec. A cleartext HTTP request is not slower
|
||||
// there, it is unintelligible: the peer reads a malformed frame and closes, so
|
||||
// the caller gets `Post "http://ai/v1/chat/completions": EOF` on every request,
|
||||
// any method, any path, first connection, peer perfectly healthy.
|
||||
//
|
||||
// THE SURFACE IS NOT THE APP'S. What binds there is the app's PLANE — the
|
||||
// typed-op door at /.well-known/zip/op/<name>, which is what plane.Ask uses
|
||||
// (plane/ask.go: "ServePlane binds before the app's own listener"). The app's
|
||||
// own HTTP routes are on a listener the plane socket knows nothing about, so
|
||||
// /v1/chat/completions is a 404 there even when the wire is spoken correctly.
|
||||
// Measured on a healthy pod: over ZAP, ai.sock answers 404 for /v1/models and
|
||||
// /v1/chat/completions alike, while ai's own listener answers 200 and 401.
|
||||
//
|
||||
// Together they are why @hanzo in Slack answered "the agent hit an error handling
|
||||
// that": the model call EOF'd, agents recorded an honest error-status run, and the
|
||||
// bridge turned that into its generic reply. `ai` never logged the request because
|
||||
// the request never arrived.
|
||||
//
|
||||
// # The address that does serve it
|
||||
//
|
||||
// The fleet ROUTER's own HTTP listener — the one CLOUD_LISTEN names and
|
||||
// api.hanzo.ai is merely the public face of. It owns the route table that sends
|
||||
// /v1/* to `ai`, and it owns starting a cold app, so reaching the model API
|
||||
// through it is not a special case: it is the same door every external caller
|
||||
// uses, entered from inside.
|
||||
//
|
||||
// On LOOPBACK, which is the whole point. The router runs in this pod, so
|
||||
// 127.0.0.1 never leaves the network namespace: no DNS, no Service hop, and
|
||||
// above all no trip out through Cloudflare and back to the pod's own public
|
||||
// address, which is what the configured base URL (https://api.hanzo.ai/v1) does
|
||||
// and what made a completion depend on the edge being willing to loop. The
|
||||
// credential is unchanged — same static key or same M2M identity, chosen the
|
||||
// same way by the pickers in build.go — because who may ask is a different
|
||||
// question from where the peer is.
|
||||
//
|
||||
// There is deliberately NO second mechanism here. A raw route reached
|
||||
// process-to-process is not something this fleet offers; ops are (plane.Ask), and
|
||||
// inventing a parallel path for the one surface that is not an op is what broke
|
||||
// it. One door, entered from inside.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// aiApp is the app name the socket is derived from. One spelling.
|
||||
// aiApp is the app name this decision is about. One spelling.
|
||||
const aiApp = "ai"
|
||||
|
||||
// aiPeerURL is the base a socket-dialed call carries. The HOST is inert — the
|
||||
// transport dials a named peer, not this address — so it names the peer for logs
|
||||
// and error text and nothing more. The /v1 prefix is real: it is the peer's own
|
||||
// route prefix.
|
||||
const aiPeerURL = "http://ai/v1"
|
||||
// aiLoopbackPort is the port assumed when the listener address names none. It
|
||||
// matches config.go's own default for CLOUD_LISTEN, so the two cannot drift into
|
||||
// disagreeing about where this binary listens.
|
||||
const aiLoopbackPort = "8080"
|
||||
|
||||
// aiRoute answers the two questions a caller has about reaching `ai`: over what
|
||||
// transport, and under what address. It is ONE decision, shared by the
|
||||
@@ -40,44 +75,33 @@ const aiPeerURL = "http://ai/v1"
|
||||
// about where the peer is.
|
||||
//
|
||||
// !Enabled(ai) means this process does not carry the app, which is exactly when
|
||||
// `ai` is a SIBLING and its socket is the honest address. The process that IS
|
||||
// `ai` keeps the configured one — routing inference back through the picker
|
||||
// there would be the process calling itself.
|
||||
// `ai` is a SIBLING and the router's loopback listener is the honest address. The
|
||||
// process that IS `ai` keeps the configured one — routing inference back through
|
||||
// the picker there would be the process calling itself.
|
||||
//
|
||||
// The transport is nil in both branches: an ordinary HTTP address is reached with
|
||||
// the ordinary transport, and the pickers' "socket" log field reads false because
|
||||
// no socket is involved. Neither branch needs a custom RoundTripper — waking a
|
||||
// cold app is the router's job, and doing it again here would be a second
|
||||
// mechanism for something that already has one.
|
||||
func aiRoute(cfg *Config) (http.RoundTripper, string) {
|
||||
if cfg.Enabled(aiApp) {
|
||||
return nil, cfg.AIBaseURL
|
||||
}
|
||||
return newSocketTransport(aiApp), aiPeerURL
|
||||
return nil, aiLoopbackURL(cfg)
|
||||
}
|
||||
|
||||
// socketRoundTripper speaks HTTP to one app over its canonical unix socket.
|
||||
// aiLoopbackURL is the router's HTTP listener as seen from inside its own pod.
|
||||
//
|
||||
// It WAKES the peer before dialing, through the same reach() every plane call
|
||||
// uses: an app is lazy by default, so a sibling that dialed a cold socket would
|
||||
// read "not deployed here" from what is really "not started yet". reach asks the
|
||||
// router, which owns the manifest, so absence and outage stay distinguishable.
|
||||
type socketRoundTripper struct {
|
||||
app string
|
||||
next http.RoundTripper
|
||||
}
|
||||
|
||||
func newSocketTransport(app string) http.RoundTripper {
|
||||
srt := &socketRoundTripper{app: app}
|
||||
srt.next = &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
// network and address are DISCARDED: the peer is named, not addressed.
|
||||
// Whatever host the base URL carries is inert here, which is why the
|
||||
// deployment no longer states one.
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", zip.SocketPath(srt.app))
|
||||
},
|
||||
// Only the PORT is taken from the configured listener: the host half is whatever
|
||||
// the process binds (":8000", "0.0.0.0:8000"), and neither is an address a client
|
||||
// may dial. 127.0.0.1 is, and it is the one that cannot leave the pod.
|
||||
func aiLoopbackURL(cfg *Config) string {
|
||||
port := aiLoopbackPort
|
||||
if addr := strings.TrimSpace(cfg.ListenAddr); addr != "" {
|
||||
if _, p, err := net.SplitHostPort(addr); err == nil && p != "" {
|
||||
port = p
|
||||
}
|
||||
}
|
||||
return srt
|
||||
}
|
||||
|
||||
func (s *socketRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
bindRuntimeDir()
|
||||
if err := reach(r.Context(), s.app); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.next.RoundTrip(r)
|
||||
return "http://127.0.0.1:" + port + "/v1"
|
||||
}
|
||||
|
||||
+51
-23
@@ -1,42 +1,69 @@
|
||||
package cloud
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A SIBLING REACHES `ai` OVER ITS SOCKET, NOT THROUGH THE INTERNET.
|
||||
// A SIBLING REACHES `ai` THROUGH THE ROUTER ON LOOPBACK.
|
||||
//
|
||||
// `ai` is a plugin of this same binary running as its own process. Addressing it
|
||||
// by its public URL sent a completion out through Cloudflare and back, and made
|
||||
// the pod mint an OAuth token to authenticate to its own deployment. Which
|
||||
// transport a process gets is decided by WHAT IT IS, never by configuration.
|
||||
func TestSiblingReachesAIOverItsSocket(t *testing.T) {
|
||||
sibling := &Config{Enable: []string{"agents"}, AIBaseURL: "https://api.hanzo.ai/v1"}
|
||||
// Two addresses are wrong here and this pins both.
|
||||
//
|
||||
// The pod's own PUBLIC url sends a completion out through Cloudflare and back,
|
||||
// and makes the pod mint an OAuth token to authenticate to its own deployment.
|
||||
//
|
||||
// The peer's PLANE SOCKET (zip.SocketPath("ai")) cannot serve it at all: that
|
||||
// socket speaks ZAP, not HTTP, and carries the typed-op door rather than the
|
||||
// app's routes — so a raw /v1 request there is first unintelligible and then, if
|
||||
// framed correctly, a 404. Reaching it that way is what made @hanzo answer "the
|
||||
// agent hit an error handling that" for every Slack turn.
|
||||
//
|
||||
// What is left is the router's own listener, entered on 127.0.0.1 so it never
|
||||
// leaves the pod. Which address a process gets is decided by WHAT IT IS, never
|
||||
// by configuration.
|
||||
func TestSiblingReachesAIThroughTheRouterOnLoopback(t *testing.T) {
|
||||
sibling := &Config{Enable: []string{"agents"}, AIBaseURL: "https://api.hanzo.ai/v1", ListenAddr: ":8000"}
|
||||
|
||||
via, base := aiRoute(sibling)
|
||||
if via == nil {
|
||||
t.Error("a sibling took the default transport — it would leave the host to reach a peer")
|
||||
if via != nil {
|
||||
t.Errorf("a sibling got a custom transport (%T) — an ordinary address is reached with the ordinary transport", via)
|
||||
}
|
||||
if base == "https://api.hanzo.ai/v1" {
|
||||
t.Error("a sibling addressed `ai` by the pod's OWN public URL")
|
||||
t.Error("a sibling addressed `ai` by the pod's OWN public URL — that leaves the host and comes back through the edge")
|
||||
}
|
||||
if base != aiPeerURL {
|
||||
t.Errorf("sibling base = %q, want the named peer %q", base, aiPeerURL)
|
||||
if want := "http://127.0.0.1:8000/v1"; base != want {
|
||||
t.Errorf("sibling base = %q, want the router on loopback %q", base, want)
|
||||
}
|
||||
srt, ok := via.(*socketRoundTripper)
|
||||
if !ok {
|
||||
t.Fatalf("transport is %T, want the socket one", via)
|
||||
}
|
||||
|
||||
// The port is READ from the configured listener rather than assumed, or a
|
||||
// deployment that moves its listener would send every completion to a closed port.
|
||||
func TestSiblingFollowsTheConfiguredListenerPort(t *testing.T) {
|
||||
for _, listen := range []string{":9100", "0.0.0.0:9100", "127.0.0.1:9100"} {
|
||||
_, base := aiRoute(&Config{Enable: []string{"agents"}, ListenAddr: listen})
|
||||
if want := "http://127.0.0.1:9100/v1"; base != want {
|
||||
t.Errorf("ListenAddr %q → %q, want %q", listen, base, want)
|
||||
}
|
||||
}
|
||||
if srt.app != aiApp {
|
||||
t.Errorf("socket targets %q, want %q — the peer is NAMED, never addressed", srt.app, aiApp)
|
||||
}
|
||||
|
||||
// A sibling never dials the peer's plane socket. That door is zip's typed-op
|
||||
// plane (plane.Ask), it does not speak HTTP, and the app's own routes are not on
|
||||
// it — so naming it here can only ever produce an EOF or a 404.
|
||||
func TestSiblingNeverDialsThePlaneSocket(t *testing.T) {
|
||||
_, base := aiRoute(&Config{Enable: []string{"agents"}, ListenAddr: ":8000"})
|
||||
if strings.Contains(base, ".sock") || strings.HasPrefix(base, "http://ai") {
|
||||
t.Errorf("sibling base = %q — that is the plane socket, which serves ops and not /v1", base)
|
||||
}
|
||||
}
|
||||
|
||||
// The process that IS `ai` keeps the configured address: routing inference back
|
||||
// through the picker there would be the process calling itself.
|
||||
func TestTheAIProcessDoesNotDialItself(t *testing.T) {
|
||||
self := &Config{Enable: []string{"ai"}, AIBaseURL: "https://api.hanzo.ai/v1"}
|
||||
self := &Config{Enable: []string{"ai"}, AIBaseURL: "https://api.hanzo.ai/v1", ListenAddr: ":8000"}
|
||||
via, base := aiRoute(self)
|
||||
if via != nil {
|
||||
t.Error("the ai process resolved itself to its own socket — it would call itself")
|
||||
t.Error("the ai process got a custom transport — it would call itself")
|
||||
}
|
||||
if base != "https://api.hanzo.ai/v1" {
|
||||
t.Errorf("ai process base = %q, want its configured address", base)
|
||||
@@ -45,8 +72,9 @@ func TestTheAIProcessDoesNotDialItself(t *testing.T) {
|
||||
|
||||
// The host carries every app, so it is not a sibling either.
|
||||
func TestTheHostIsNotASibling(t *testing.T) {
|
||||
host := &Config{AIBaseURL: "https://api.hanzo.ai/v1"} // empty Enable = carries all
|
||||
if via, _ := aiRoute(host); via != nil {
|
||||
t.Error("the host took the sibling path while carrying `ai` itself")
|
||||
host := &Config{AIBaseURL: "https://api.hanzo.ai/v1", ListenAddr: ":8000"} // empty Enable = carries all
|
||||
_, base := aiRoute(host)
|
||||
if base != "https://api.hanzo.ai/v1" {
|
||||
t.Errorf("host base = %q, want its configured address", base)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/hanzoai/cloud/apps/sites"
|
||||
"github.com/zap-proto/zip"
|
||||
"github.com/zap-proto/zip/middleware"
|
||||
@@ -29,16 +33,18 @@ import (
|
||||
// the kernel answers which process is calling, and the boundary's findings travel
|
||||
// with the request.
|
||||
//
|
||||
// name is what the program calls itself in a diagnostic. tools is the MCP surface,
|
||||
// which only a program holding a subsystem list can project — everyone else passes
|
||||
// nil and serves none.
|
||||
// name is what the program calls itself in a diagnostic. tools is the per-caller
|
||||
// half of this program's agent door — the tools that exist because of WHO is
|
||||
// asking, which only a program holding a subsystem list can declare; everyone
|
||||
// else passes nil and offers none. The door itself is not optional either way:
|
||||
// see [callerTools].
|
||||
func App(name string, cfg *Config, deps Deps, tools zip.Source) *zip.App {
|
||||
app := zip.New(zip.Config{
|
||||
AppName: name,
|
||||
Logger: deps.Logger,
|
||||
ReadBufferSize: cfg.ReadBufferSize,
|
||||
BodyLimit: cfg.BodyLimit,
|
||||
MCP: zip.MCPConfig{Source: tools},
|
||||
MCP: zip.MCPConfig{Source: callerTools(tools)},
|
||||
// Cloud's refusal renderer, in place of zip's default — which reads only a
|
||||
// *zip.HTTPError and answers 500 for everything else, so a propagated 402
|
||||
// or 403 reached the console as a dead card. See errmap.go.
|
||||
@@ -150,6 +156,56 @@ func App(name string, cfg *Config, deps Deps, tools zip.Source) *zip.App {
|
||||
return app
|
||||
}
|
||||
|
||||
// callerTools is this program's per-caller tool half — and stating it, rather
|
||||
// than leaving it nil, is what makes the agent door UNCONDITIONAL.
|
||||
//
|
||||
// zip mounts the door only for an app that has something to project: a typed op,
|
||||
// a composed plugin's catalogue, or a per-caller Source. With all three absent it
|
||||
// returns before registering the route at all (zip@v1.25.1 mcp.go:99). That is
|
||||
// the right default for a program nobody interrogates, and the wrong one for
|
||||
// every program built here, because the fleet's door ASKS EVERY COMPOSED
|
||||
// SUBSYSTEM on each tools/list (fleet.Ask). A subsystem whose routes are all raw
|
||||
// — a reverse proxy, or a surface owned by another module — projects no typed op,
|
||||
// so nothing claimed POST /mcp in its process, so the ask fell through to the
|
||||
// console's terminal handler and was answered with the signpost that is correct
|
||||
// only on the front door: 308 → /v1/mcp, an address a child does not serve
|
||||
// (webui/mcp.go:44). Thirty of the fleet's subsystems — the whole of exec, tasks,
|
||||
// agent, ask, websearch, crawl, index, kms, billing, platform and twenty more —
|
||||
// were reported UNREACHABLE that way while every one of them was up, healthy and
|
||||
// serving its REST surface.
|
||||
//
|
||||
// A door whose registry is empty answers {"tools":[]}, and that is a REAL answer:
|
||||
// "asked, and serves nothing" is a different fact from "could not be asked", and
|
||||
// keeping those two apart is the whole of package fleet. Its hanzo.ai/unavailable
|
||||
// list means nothing while a healthy subsystem has no way to say the first one.
|
||||
//
|
||||
// nil in, empty out. A program that declares no Plugin.Door still HAS a
|
||||
// per-caller half; it simply holds no tools. It is consulted once per tools/list
|
||||
// that names an org, answers nothing, and zip then returns the same pre-rendered
|
||||
// bytes it always did — the memcpy that makes tools/list free is untouched (zip
|
||||
// listTools: len(mine) == 0 ⇒ the build-time array, verbatim).
|
||||
func callerTools(declared zip.Source) zip.Source {
|
||||
if declared != nil {
|
||||
return declared
|
||||
}
|
||||
return noCallerTools{}
|
||||
}
|
||||
|
||||
// noCallerTools is the per-caller half of a program that declares none: no tools
|
||||
// exist because of who is asking, and a name nobody projected is nobody's.
|
||||
//
|
||||
// Its Call is reached only for a name the build-time catalogue did not claim, and
|
||||
// it answers with the same sentence zip's own miss does — the fleet's door never
|
||||
// routes one here (it refuses an unlisted name itself, fleet/mcp.go), so this is
|
||||
// the reply to a client that guessed.
|
||||
type noCallerTools struct{}
|
||||
|
||||
func (noCallerTools) Tools(context.Context) []map[string]any { return nil }
|
||||
|
||||
func (noCallerTools) Call(_ context.Context, name string, _ json.RawMessage) (any, error) {
|
||||
return nil, fmt.Errorf("unknown tool: %s", name)
|
||||
}
|
||||
|
||||
// Identify gives an app a trustworthy answer to who is calling, and makes that
|
||||
// answer reachable from every route beneath it. App does this for every program,
|
||||
// which is the only reason it can no longer be skipped.
|
||||
|
||||
+58
-16
@@ -892,7 +892,7 @@ func runAgent(s *cloud.Service[state], ctx context.Context, a Agent, input, acto
|
||||
return Run{}, err
|
||||
}
|
||||
|
||||
r := executeRun(ctx, s.State.ai, a.Org, a, input, s.State.failoverModel)
|
||||
r := executeRun(ctx, s.State.ai, a.Org, actor, a, input, s.State.failoverModel)
|
||||
span.SetAttributes(
|
||||
attribute.String("hanzo.agent.run_id", r.ID),
|
||||
attribute.String("hanzo.agent.run_status", r.Status),
|
||||
@@ -950,15 +950,23 @@ const (
|
||||
)
|
||||
|
||||
// executeRun composes the agent's instructions with the caller input and runs
|
||||
// one chat completion through the AI client — with a bounded retry on transient
|
||||
// upstream overload and, if the agent's own model stays throttled, ONE failover
|
||||
// to the deployment's reliable model (fallback) so an autonomous bot reply still
|
||||
// lands. It returns the resulting Run — status "ok" with output and Model set to
|
||||
// the model that ACTUALLY answered (so metering bills that model), or "error"
|
||||
// with the final upstream failure. Pure of HTTP and persistence so it is directly
|
||||
// testable; the caller records + responds. This reliability policy is the agent
|
||||
// runner's ALONE — the interactive user-facing chat path is untouched.
|
||||
func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, input, fallback string) Run {
|
||||
// the agent — with a bounded retry on transient upstream overload and, if the
|
||||
// agent's own model stays throttled, ONE failover to the deployment's reliable
|
||||
// model (fallback) so an autonomous bot reply still lands. It returns the
|
||||
// resulting Run — status "ok" with output and Model set to the model that
|
||||
// ACTUALLY answered (so metering bills that model), or "error" with the final
|
||||
// upstream failure. Pure of HTTP and persistence so it is directly testable; the
|
||||
// caller records + responds. This reliability policy is the agent runner's ALONE
|
||||
// — the interactive user-facing chat path is untouched.
|
||||
//
|
||||
// An agent that declares TOOLS and whose tools the plane actually offers runs the
|
||||
// bounded tool loop instead of a single completion (tools.go). One with none —
|
||||
// or one whose declared names resolve to nothing — takes the single completion
|
||||
// this has always been, unchanged.
|
||||
//
|
||||
// actor is the run's billing identity (billingActor's "org/sub"), threaded so a
|
||||
// tool dispatch runs as the principal the run is charged to.
|
||||
func executeRun(ctx context.Context, ai types.AIClient, org, actor string, a Agent, input, fallback string) Run {
|
||||
// Child step span; the AI client opens its own GenAI span nested under this.
|
||||
ctx, span := agentTracer.Start(ctx, "agent.step", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
@@ -972,7 +980,33 @@ func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, inp
|
||||
prompt += in
|
||||
}
|
||||
start := time.Now()
|
||||
resp, used, aiErr := completeWithFailover(ctx, ai, org, prompt, a.Model, fallback)
|
||||
var (
|
||||
resp *types.ChatResponse
|
||||
used string
|
||||
aiErr error
|
||||
)
|
||||
// An agent nested at the depth limit is offered nothing and has to answer for
|
||||
// itself — the one thing that stops a cycle of agents-as-tools, since each
|
||||
// level would otherwise start its round cap over (tools.go).
|
||||
var offer []string
|
||||
if agentDepth(ctx) < maxAgentDepth {
|
||||
offer = callableTools(a)
|
||||
}
|
||||
defs := runTools.catalog(ctx, org, actor, offer)
|
||||
// BOTH numbers, always. An agent that declares tools and is offered none is
|
||||
// the exact shape of the split-fleet gap tools.go describes, and it is only
|
||||
// diagnosable if the span says "declared 3, offered 0" rather than staying
|
||||
// silent about a run that quietly had no hands.
|
||||
span.SetAttributes(
|
||||
attribute.Int("hanzo.agent.tools_declared", len(a.Tools)),
|
||||
attribute.Int("hanzo.agent.tools", len(defs)),
|
||||
)
|
||||
if len(defs) > 0 {
|
||||
resp, used, aiErr = completeWithTools(ctx, ai, org, actor, prompt, a.Model, fallback, defs)
|
||||
} else {
|
||||
resp, used, aiErr = completeWithFailover(ctx, ai,
|
||||
&types.ChatRequest{Model: a.Model, Org: org, Prompt: prompt}, fallback)
|
||||
}
|
||||
dur := time.Since(start).Milliseconds()
|
||||
id, _ := genID("run")
|
||||
r := Run{
|
||||
@@ -993,20 +1027,28 @@ func executeRun(ctx context.Context, ai types.AIClient, org string, a Agent, inp
|
||||
return r
|
||||
}
|
||||
|
||||
// completeWithFailover runs the completion on the agent's model with a bounded
|
||||
// completeWithFailover runs one completion on req's own model with a bounded
|
||||
// retry (completeWithRetry), then — only if that model is STILL throttled after
|
||||
// its retries — fails over ONCE to fallback, a reliable model. It returns the
|
||||
// response, the model that actually produced it (for honest metering), and the
|
||||
// final error. A non-transient failure on either model returns immediately (the
|
||||
// next model would fail identically). ONE ordered mechanism, no config sprawl.
|
||||
func completeWithFailover(ctx context.Context, ai types.AIClient, org, prompt, model, fallback string) (*types.ChatResponse, string, error) {
|
||||
//
|
||||
// It takes the whole request rather than a prompt string because a tool round IS
|
||||
// the request: the transcript so far and the tools on offer are part of what is
|
||||
// being retried, and a helper that only knew a prompt would have to grow a second
|
||||
// copy of this policy for the loop to reuse (tools.go). req.Model is set per
|
||||
// attempt; everything else is the caller's.
|
||||
func completeWithFailover(ctx context.Context, ai types.AIClient, req *types.ChatRequest, fallback string) (*types.ChatResponse, string, error) {
|
||||
model := req.Model
|
||||
models := []string{model}
|
||||
if f := strings.TrimSpace(fallback); f != "" && f != model {
|
||||
models = append(models, f)
|
||||
}
|
||||
var lastErr error
|
||||
for _, m := range models {
|
||||
resp, err := completeWithRetry(ctx, ai, org, prompt, m)
|
||||
req.Model = m
|
||||
resp, err := completeWithRetry(ctx, ai, req)
|
||||
if err == nil {
|
||||
return resp, m, nil
|
||||
}
|
||||
@@ -1023,10 +1065,10 @@ func completeWithFailover(ctx context.Context, ai types.AIClient, org, prompt, m
|
||||
// completeWithRetry calls the completion up to maxAttempts times, retrying ONLY a
|
||||
// transient upstream overload (types.ErrUpstreamBusy) with jittered backoff and
|
||||
// respecting context cancellation. A non-transient error returns immediately.
|
||||
func completeWithRetry(ctx context.Context, ai types.AIClient, org, prompt, model string) (*types.ChatResponse, error) {
|
||||
func completeWithRetry(ctx context.Context, ai types.AIClient, req *types.ChatRequest) (*types.ChatResponse, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
resp, err := ai.ChatCompletion(ctx, &types.ChatRequest{Model: model, Prompt: prompt, Org: org})
|
||||
resp, err := ai.ChatCompletion(ctx, req)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestExecuteRunOK(t *testing.T) {
|
||||
ai := &fakeAI{content: "hi there"}
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Instructions = "You are a greeter."
|
||||
r := executeRun(context.Background(), ai, "maxpower", a, "say hi", "")
|
||||
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "say hi", "")
|
||||
|
||||
if r.Status != "ok" {
|
||||
t.Fatalf("want ok, got %q err=%q", r.Status, r.Error)
|
||||
@@ -177,7 +177,7 @@ func TestExecuteRunOK(t *testing.T) {
|
||||
|
||||
func TestExecuteRunRecordsError(t *testing.T) {
|
||||
ai := &fakeAI{err: errors.New("model unavailable")}
|
||||
r := executeRun(context.Background(), ai, "maxpower", mk("maxpower", "x"), "in", "")
|
||||
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", mk("maxpower", "x"), "in", "")
|
||||
if r.Status != "error" {
|
||||
t.Fatalf("want error status, got %q", r.Status)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An org that connected Slack and did nothing else has NO agent rows, and the
|
||||
// bridges ask for the conventional ref — so without a built-in default @hanzo
|
||||
// answers "the agent hit an error handling that" in every fresh workspace.
|
||||
func TestBuiltinResolvesTheConventionalRef(t *testing.T) {
|
||||
a, ok := builtinAgent("acme", "hanzo", "zen-70b")
|
||||
if !ok {
|
||||
t.Fatal("the conventional ref must resolve to the built-in default")
|
||||
}
|
||||
if a.Org != "acme" {
|
||||
t.Errorf("the default must be scoped to the asking org, got %q", a.Org)
|
||||
}
|
||||
if a.Model != "zen-70b" {
|
||||
t.Errorf("the default must use the deployment's model, got %q", a.Model)
|
||||
}
|
||||
if len(a.Tools) != 0 {
|
||||
t.Errorf("the default carries no tools; the tool loop decides that, got %v", a.Tools)
|
||||
}
|
||||
if a.Instructions == "" {
|
||||
t.Error("the default must know what it is")
|
||||
}
|
||||
}
|
||||
|
||||
// Case is not a reason to fail: Slack sends whatever the user typed.
|
||||
func TestBuiltinIsCaseInsensitive(t *testing.T) {
|
||||
if _, ok := builtinAgent("acme", "Hanzo", "m"); !ok {
|
||||
t.Error("the ref must match case-insensitively")
|
||||
}
|
||||
}
|
||||
|
||||
// An UNKNOWN ref stays unknown. Silently substituting the chat agent would make
|
||||
// a typo in `code: repo` run the wrong thing and look like it worked.
|
||||
func TestUnknownRefIsStillAMiss(t *testing.T) {
|
||||
for _, ref := range []string{"deployer", "hanzo-coder", "", "hanz"} {
|
||||
if _, ok := builtinAgent("acme", ref, "m"); ok {
|
||||
t.Errorf("%q must not resolve to the default", ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No model configured is an honest miss, not a run that fails deeper in.
|
||||
func TestNoModelIsAMiss(t *testing.T) {
|
||||
if _, ok := builtinAgent("acme", "hanzo", " "); ok {
|
||||
t.Error("with no model configured the default must not resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// The chat brain is enso — Hanzo's own auto-routing SKU — and explicitly NOT
|
||||
// cloud.FallbackModel ("best"), whose own doc says the interactive chat path
|
||||
// never uses it. A Slack turn IS the interactive chat path.
|
||||
func TestBuiltinModelIsEnso(t *testing.T) {
|
||||
t.Setenv("BRIDGE_AGENT_MODEL", "")
|
||||
if got := builtinAgentModel(); got != "enso" {
|
||||
t.Errorf("the chat brain must default to enso, got %q", got)
|
||||
}
|
||||
if got := builtinAgentModel(); got == "best" {
|
||||
t.Error(`"best" is the degraded fallback tier, never the interactive default`)
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment can name its own.
|
||||
func TestBuiltinModelOverride(t *testing.T) {
|
||||
t.Setenv("BRIDGE_AGENT_MODEL", "enso-ultra")
|
||||
if got := builtinAgentModel(); got != "enso-ultra" {
|
||||
t.Errorf("BRIDGE_AGENT_MODEL must win, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package agents
|
||||
|
||||
// door.go — where a run's tools come from once the fleet is more than one
|
||||
// process: the fleet's OWN agent door, asked over the internal socket.
|
||||
//
|
||||
// # Why this is not a new mechanism
|
||||
//
|
||||
// The fleet already aggregates. fleet.Door asks every composed app what it
|
||||
// serves right now, merges the answers, remembers which app listed which name,
|
||||
// and forwards a tools/call to that app (fleet/mcp.go). It is what serves
|
||||
// api.hanzo.ai/v1/mcp and what a Slack MCP client already talks to. Building a
|
||||
// tools_catalog/tools_call op pair on the tool plane would have been a SECOND
|
||||
// aggregation over the same children, with a second place for the curation rule
|
||||
// to be applied — or forgotten.
|
||||
//
|
||||
// So nothing here aggregates. The host publishes the door it already built on
|
||||
// the socket every child can already reach (cmd/cloud/wake.go), and an agent is
|
||||
// simply another MCP client of it. Same JSON-RPC, same union, same order, same
|
||||
// [fleet] denylist — which is enforced inside gather, where the routing table is
|
||||
// written, so a name the door will not project is not routable for anyone. An
|
||||
// agent therefore CANNOT see a surface an external client cannot; there is no
|
||||
// second surface to see.
|
||||
//
|
||||
// # The address, and what "no door" means
|
||||
//
|
||||
// plane.HostApp is the router's own socket — the one plane.Reach dials to wake a
|
||||
// cold app — and the door rides it at manifest.MCPPath. Reaching for it answers
|
||||
// one of exactly three things, which is the rule plane/ask.go already states:
|
||||
//
|
||||
// listening ask it; this is production
|
||||
// no listener THIS PROCESS IS THE FLEET — a single-app binary, a test, a dev
|
||||
// box. Fall back to [registryTools], which is the real answer
|
||||
// there and empty everywhere else.
|
||||
// unusable an outage. Zero tools, recorded on the run's span, never
|
||||
// laundered into "this fleet has no tools".
|
||||
//
|
||||
// # Identity is stated by the RUN, and the model never touches it
|
||||
//
|
||||
// The org and actor a dispatch carries are the run's own — the pair its fee is
|
||||
// billed under — passed as arguments from executeRun and written onto the
|
||||
// request as zip's identity headers here. The model contributes a tool NAME and
|
||||
// an ARGUMENTS object and nothing else, so there is no path by which it can name
|
||||
// a tenant. The inbound caller's headers are deliberately NOT forwarded: a
|
||||
// scheduled run has no inbound request at all, and a nested one may be running
|
||||
// for a different principal than whoever made the outermost HTTP call.
|
||||
//
|
||||
// The socket carries no credential and needs none: it is 0700 in the fleet's own
|
||||
// run directory and the kernel attests the peer, which is the same trust
|
||||
// zip.WithCaller rides on for every other internal call.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/hanzoai/cloud/fleet"
|
||||
"github.com/hanzoai/cloud/manifest"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
"github.com/valyala/fasthttp"
|
||||
zaphttp "github.com/zap-proto/http"
|
||||
"github.com/zap-proto/zip"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// doorTools is the tool plane read from the FLEET's composed agent door.
|
||||
type doorTools struct{}
|
||||
|
||||
// errNoDoor reports that this process is not part of a fleet: nothing is
|
||||
// listening on the router's socket, so there is no composed door to ask.
|
||||
//
|
||||
// It is the ONE error a caller may read as "fall back", exactly as plane.ErrNoPeer
|
||||
// is on the peer plane. Every other failure is an outage and is reported as one —
|
||||
// a door that is present and broken must never read as a fleet with no tools.
|
||||
var errNoDoor = errors.New("agents: no fleet door on this host")
|
||||
|
||||
// catalog resolves the agent's declared names against the fleet's own surface.
|
||||
//
|
||||
// It asks the door WHAT IS OFFERED and then, for the handful of names this agent
|
||||
// declared, what each one takes. Those are two questions because the door's
|
||||
// tools/list answers only the first: it publishes one tool per subsystem, whose
|
||||
// `op` enum carries the operation names and no schemas, since the flat list of
|
||||
// this fleet's operations was 977 KB that no model can hold and every client
|
||||
// truncates (fleet/grouped.go). fleet.Describe answers the second, one operation
|
||||
// at a time, out of the same gathered set — so a declared name the door does not
|
||||
// offer is simply absent, which is the same rule registryTools follows: offering
|
||||
// a tool that would be refused at dispatch teaches the model a lie.
|
||||
func (doorTools) catalog(ctx context.Context, org, actor string, want []string) []types.ToolDef {
|
||||
if org == "" || len(want) == 0 {
|
||||
return nil
|
||||
}
|
||||
wanted := make(map[string]bool, len(want))
|
||||
for _, n := range want {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
wanted[n] = true
|
||||
}
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
return nil
|
||||
}
|
||||
res, err := askDoor(ctx, org, actor, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
if errors.Is(err, errNoDoor) {
|
||||
return registryTools{}.catalog(ctx, org, actor, want)
|
||||
}
|
||||
if err != nil {
|
||||
// An outage, and it is SAID so. The run continues with no tools — killing
|
||||
// a turn the org has paid for because a sibling is down is the worse
|
||||
// answer — but "declared 3, offered 0" is already a number on the step
|
||||
// span, and this is the reason beside it.
|
||||
trace.SpanFromContext(ctx).RecordError(err)
|
||||
return nil
|
||||
}
|
||||
var listed struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"inputSchema"`
|
||||
} `json:"tools"`
|
||||
// Meta is the door's own account of why its list may be SHORT: the
|
||||
// subsystems it could not ask, and how many names policy withheld. The door
|
||||
// went to the trouble of never shortening quietly, so throwing it away here
|
||||
// would put the silence back one layer down.
|
||||
Meta json.RawMessage `json:"_meta"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &listed); err != nil {
|
||||
trace.SpanFromContext(ctx).RecordError(fmt.Errorf("agents: the fleet door's tools/list is not a tool list: %w", err))
|
||||
return nil
|
||||
}
|
||||
offered := map[string]bool{}
|
||||
for _, t := range listed.Tools {
|
||||
for _, op := range opsOf(t.InputSchema) {
|
||||
offered[op] = true
|
||||
}
|
||||
}
|
||||
// In the agent's own declared order, which is the order the model meets them
|
||||
// in, and once each however often it was declared.
|
||||
out := make([]types.ToolDef, 0, len(wanted))
|
||||
done := make(map[string]bool, len(wanted))
|
||||
for _, n := range want {
|
||||
n = strings.TrimSpace(n)
|
||||
if !wanted[n] || done[n] || !offered[n] {
|
||||
continue
|
||||
}
|
||||
done[n] = true
|
||||
def, err := describe(ctx, org, actor, n)
|
||||
if err != nil {
|
||||
// It was offered a moment ago, so this is an outage between the two
|
||||
// asks and not a refusal. Same policy as above: the turn goes on with
|
||||
// one fewer tool, and the reason is on the span.
|
||||
trace.SpanFromContext(ctx).RecordError(err)
|
||||
continue
|
||||
}
|
||||
out = append(out, def)
|
||||
}
|
||||
// A declared name that resolved to nothing has two very different causes — a
|
||||
// subsystem that is DOWN and a tool the fleet REFUSES to project — and the
|
||||
// door already distinguishes them. Carrying its answer onto the span is what
|
||||
// makes "declared 3, offered 1" diagnosable instead of a shrug.
|
||||
if len(out) < len(wanted) && len(listed.Meta) > 0 {
|
||||
trace.SpanFromContext(ctx).SetAttributes(
|
||||
attribute.String("hanzo.agent.tools_meta", clip(string(listed.Meta), maxDoorMeta)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// opsOf reads the operation names out of one subsystem tool's schema — its `op`
|
||||
// enum, which is where the door carries them.
|
||||
func opsOf(schema json.RawMessage) []string {
|
||||
var s struct {
|
||||
Properties struct {
|
||||
Op struct {
|
||||
Enum []string `json:"enum"`
|
||||
} `json:"op"`
|
||||
} `json:"properties"`
|
||||
}
|
||||
if json.Unmarshal(schema, &s) != nil {
|
||||
return nil
|
||||
}
|
||||
return s.Properties.Op.Enum
|
||||
}
|
||||
|
||||
// describe fetches ONE operation's descriptor through the door's own
|
||||
// fleet.Describe, and reads the owning subsystem's bytes back out of it.
|
||||
//
|
||||
// The name check is not paranoia about the door: it is what makes "the model is
|
||||
// offered exactly what it will call" true at the seam, since a descriptor under
|
||||
// another name would put a schema in front of the model for a tool it cannot
|
||||
// reach.
|
||||
func describe(ctx context.Context, org, actor, op string) (types.ToolDef, error) {
|
||||
args, err := json.Marshal(map[string]string{"op": op})
|
||||
if err != nil {
|
||||
return types.ToolDef{}, err
|
||||
}
|
||||
body, err := toolCallBody(fleet.Describe, string(args))
|
||||
if err != nil {
|
||||
return types.ToolDef{}, err
|
||||
}
|
||||
res, err := askDoor(ctx, org, actor, body)
|
||||
if err != nil {
|
||||
return types.ToolDef{}, err
|
||||
}
|
||||
text, err := toolResult(res)
|
||||
if err != nil {
|
||||
return types.ToolDef{}, err
|
||||
}
|
||||
var d struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"inputSchema"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(text), &d); err != nil || d.Name != op {
|
||||
return types.ToolDef{}, fmt.Errorf("agents: %s did not answer %s's own descriptor", fleet.Describe, op)
|
||||
}
|
||||
return types.ToolDef{Name: d.Name, Description: d.Description, Schema: d.InputSchema}, nil
|
||||
}
|
||||
|
||||
// maxDoorMeta bounds what one span attribute may carry: `_meta` names every
|
||||
// subsystem that did not answer, and a fleet-wide outage would otherwise put a
|
||||
// hundred rows on every run's trace.
|
||||
const maxDoorMeta = 1024
|
||||
|
||||
func clip(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// call runs one tool through the door's own dispatch: the door names the app
|
||||
// that listed it and forwards this message verbatim to that app's registry, so
|
||||
// the host can only ever ROUTE a call and never invoke something the owner did
|
||||
// not declare.
|
||||
func (doorTools) call(ctx context.Context, org, actor, name, args string) (string, error) {
|
||||
body, err := toolCallBody(name, args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := askDoor(ctx, org, actor, body)
|
||||
if errors.Is(err, errNoDoor) {
|
||||
return registryTools{}.call(ctx, org, actor, name, args)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return toolResult(res)
|
||||
}
|
||||
|
||||
// toolCallBody builds one MCP tools/call, with the model's arguments carried
|
||||
// VERBATIM.
|
||||
//
|
||||
// The arguments are validated as a JSON OBJECT and then embedded unparsed: they
|
||||
// belong to the tool that declared the schema, which is the only thing that
|
||||
// knows how to read them, and re-encoding them here would be this process having
|
||||
// an opinion about a shape it does not own. A model that emits something else is
|
||||
// told so — the same sentence registryTools gives it — and the turn goes on.
|
||||
func toolCallBody(name, args string) ([]byte, error) {
|
||||
raw := json.RawMessage("{}")
|
||||
if s := strings.TrimSpace(args); s != "" && s != "null" {
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(s), &probe); err != nil {
|
||||
return nil, fmt.Errorf("arguments are not a JSON object: %w", err)
|
||||
}
|
||||
raw = json.RawMessage(s)
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
} `json:"params"`
|
||||
}{
|
||||
JSONRPC: "2.0", ID: 1, Method: "tools/call",
|
||||
Params: struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}{Name: name, Arguments: raw},
|
||||
})
|
||||
}
|
||||
|
||||
// toolResult reads one MCP tool result into the text the model is handed.
|
||||
//
|
||||
// isError is a FAILURE and comes back as one, so dispatchOne renders it as a
|
||||
// tool result the model can react to rather than as a success it would believe.
|
||||
// That is the same distinction the door itself draws when a hop fails.
|
||||
func toolResult(res json.RawMessage) (string, error) {
|
||||
var out struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &out); err != nil {
|
||||
return "", fmt.Errorf("agents: the fleet door answered a tool result that will not decode: %w", err)
|
||||
}
|
||||
parts := make([]string, 0, len(out.Content))
|
||||
for _, c := range out.Content {
|
||||
if c.Text != "" {
|
||||
parts = append(parts, c.Text)
|
||||
}
|
||||
}
|
||||
text := strings.Join(parts, "\n")
|
||||
if out.IsError {
|
||||
// The tool's OWN sentence, so the model reads what actually went wrong.
|
||||
if text == "" {
|
||||
text = "the tool reported a failure with no message"
|
||||
}
|
||||
return "", errors.New(truncateToolResult(text))
|
||||
}
|
||||
return truncateToolResult(text), nil
|
||||
}
|
||||
|
||||
// askDoor puts one JSON-RPC message to the fleet's door as (org, actor) and
|
||||
// returns the `result` member.
|
||||
//
|
||||
// A JSON-RPC ERROR is an error here, deliberately: a tool the door will not
|
||||
// route answers -32602, and folding that into an empty result would make "this
|
||||
// tool is not yours to call" indistinguishable from "it ran and said nothing".
|
||||
func askDoor(ctx context.Context, org, actor string, body []byte) (json.RawMessage, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addr, err := doorAddr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := fasthttp.AcquireRequest()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
resp := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
req.Header.SetMethod(fasthttp.MethodPost)
|
||||
req.Header.SetContentType("application/json")
|
||||
req.SetHost(plane.HostApp)
|
||||
req.URI().SetPath(manifest.MCPPath)
|
||||
// The RUN's identity, in zip's own spelling, and nothing else. The door
|
||||
// copies these onto every hop it makes, so a subsystem whose tools depend on
|
||||
// the tenant answers for the org this run is billed to. A blank subject is a
|
||||
// run with no person behind it (a schedule, a service token); the org is the
|
||||
// authority either way and inventing a user would attribute the call to
|
||||
// nobody.
|
||||
req.Header.Set(zip.HeaderOrg, org)
|
||||
if sub := actorSub(org, actor); sub != "" {
|
||||
req.Header.Set(zip.HeaderUser, sub)
|
||||
}
|
||||
req.SetBody(body)
|
||||
|
||||
if err := doorClient(addr).Do(req, resp); err != nil {
|
||||
return nil, fmt.Errorf("agents: the fleet door at %s did not answer: %w", addr, err)
|
||||
}
|
||||
if code := resp.StatusCode(); code < 200 || code > 299 {
|
||||
return nil, fmt.Errorf("agents: the fleet door answered %d", code)
|
||||
}
|
||||
var env struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body(), &env); err != nil {
|
||||
return nil, fmt.Errorf("agents: the fleet door answered something that is not JSON-RPC: %w", err)
|
||||
}
|
||||
if env.Error != nil {
|
||||
return nil, errors.New(env.Error.Message)
|
||||
}
|
||||
return append(json.RawMessage(nil), env.Result...), nil
|
||||
}
|
||||
|
||||
// doorAddr resolves the fleet door's socket, or says which of the two failures
|
||||
// it is. See [errNoDoor].
|
||||
//
|
||||
// It probes by CONNECTING, because the file does not answer the question: a
|
||||
// socket path outlives the process that bound it wherever the run directory is a
|
||||
// volume. plane.Listening is the one implementation of that rule.
|
||||
func doorAddr() (string, error) {
|
||||
plane.Bind()
|
||||
path := zip.SocketPath(plane.HostApp)
|
||||
up, err := plane.Listening(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("agents: the fleet door's socket is unusable: %w", err)
|
||||
}
|
||||
if !up {
|
||||
return "", fmt.Errorf("%w (%s)", errNoDoor, path)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// doorClients is one pooled transport per ADDRESS, for the reason fleet keeps
|
||||
// one: a transport holds a connection pool, so dialing per ask turns every tool
|
||||
// call into a fresh connect. Keyed by address rather than kept in a single var
|
||||
// because a test points the run directory somewhere else.
|
||||
var doorClients sync.Map // addr -> *zaphttp.Transport
|
||||
|
||||
func doorClient(addr string) *zaphttp.Transport {
|
||||
if c, ok := doorClients.Load(addr); ok {
|
||||
return c.(*zaphttp.Transport)
|
||||
}
|
||||
t := zaphttp.Dial("unix", addr)
|
||||
// The whole run's ceiling, not the library's 30s. A tools/list is a fan-out
|
||||
// across every composed app and the first ask of a cold one pays that app's
|
||||
// startup, so a transport that gave up sooner than the run does would report
|
||||
// an outage for a fleet that was merely waking up.
|
||||
t.SetReadTimeout(toolRunBudget)
|
||||
c, _ := doorClients.LoadOrStore(addr, t)
|
||||
return c.(*zaphttp.Transport)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package agents
|
||||
|
||||
// door_test.go — the tool plane, over the wire it actually uses.
|
||||
//
|
||||
// Nothing is stubbed at the seam under test. Every test here brings up real
|
||||
// subsystem apps on real ZAP sockets, composes the REAL fleet.Door over them,
|
||||
// publishes it on the router's socket exactly as cmd/cloud/wake.go does, and
|
||||
// then drives doorTools — so what is asserted is what a deployed agent gets.
|
||||
//
|
||||
// A fake door would have proved nothing: the two properties worth having are
|
||||
// that the agent inherits the door's CURATION and that the run's org reaches the
|
||||
// owning subsystem, and both live in code a stub would have replaced.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/fleet"
|
||||
"github.com/hanzoai/cloud/manifest"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
type echoIn struct {
|
||||
Say string `json:"say"`
|
||||
}
|
||||
|
||||
type echoOut struct {
|
||||
App string `json:"app"`
|
||||
Say string `json:"say"`
|
||||
Org string `json:"org"`
|
||||
}
|
||||
|
||||
// subsystem starts one app serving the named ops on its own socket, the shape
|
||||
// cloud.Serve gives every plugin binary. Each op echoes its input AND the org it
|
||||
// was reached as, so a test can prove the run's tenant travelled the whole way.
|
||||
func subsystem(t *testing.T, name string, ops ...string) string {
|
||||
t.Helper()
|
||||
sock := shortDir(t) + "/" + name + ".sock"
|
||||
app := zip.New(zip.Config{AppName: name, DisableStartupMessage: true})
|
||||
for _, id := range ops {
|
||||
zip.Post(app, "/v1/"+name+"/"+id, func(ctx context.Context, in *echoIn) (*echoOut, error) {
|
||||
return &echoOut{App: name, Say: in.Say, Org: zip.CallerOf(ctx).Org}, nil
|
||||
}, zip.WithOperationID(id), zip.WithSummary("what "+name+" does at "+id))
|
||||
}
|
||||
go func() { _ = app.Listen(sock) }()
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
accepts(t, sock)
|
||||
return sock
|
||||
}
|
||||
|
||||
// fleetDoor composes the real door over those apps and puts it where a child
|
||||
// looks for it — plane.HostApp's socket, at manifest.MCPPath. This is
|
||||
// serveWake's two lines, not a reimplementation of them.
|
||||
func fleetDoor(t *testing.T, at map[string]string) {
|
||||
t.Helper()
|
||||
run := shortDir(t)
|
||||
t.Setenv("ZIP_RUNTIME_DIR", run)
|
||||
plane.Unbind()
|
||||
t.Cleanup(plane.Unbind)
|
||||
|
||||
host := zip.New(zip.Config{AppName: "cloud", DisableStartupMessage: true, MCP: zip.MCPConfig{Disabled: true}})
|
||||
apps := make([]string, 0, len(at))
|
||||
for name := range at {
|
||||
apps = append(apps, name)
|
||||
}
|
||||
d := fleet.Mount(host, manifest.MCPPath, apps, func(app string) (string, error) {
|
||||
sock, ok := at[app]
|
||||
if !ok {
|
||||
return "", &net.AddrError{Err: "no instance running", Addr: app}
|
||||
}
|
||||
return sock, nil
|
||||
})
|
||||
|
||||
door := zip.New(zip.Config{AppName: "plane", DisableStartupMessage: true})
|
||||
d.Serve(door, manifest.MCPPath)
|
||||
path := zip.SocketPath(plane.HostApp)
|
||||
go func() { _ = door.Listen(path) }()
|
||||
t.Cleanup(func() { _ = door.Shutdown() })
|
||||
accepts(t, path)
|
||||
}
|
||||
|
||||
// noFleetDoor points the run directory at an empty one: nothing is listening, so
|
||||
// this process is the whole fleet.
|
||||
func noFleetDoor(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("ZIP_RUNTIME_DIR", shortDir(t))
|
||||
plane.Unbind()
|
||||
t.Cleanup(plane.Unbind)
|
||||
}
|
||||
|
||||
// shortDir is a temp directory with a SHORT name, because a unix socket path is
|
||||
// capped near 104 bytes and t.TempDir() spends most of that on the test's name.
|
||||
func shortDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("", "ag")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
return dir
|
||||
}
|
||||
|
||||
func accepts(t *testing.T, sock string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if c, err := net.Dial("unix", sock); err == nil {
|
||||
_ = c.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("%s never accepted", sock)
|
||||
}
|
||||
|
||||
// TestAgentResolvesItsToolsFromTheFleetDoor is the whole claim: a declared name
|
||||
// resolves to the OWNING subsystem's own descriptor, across a process boundary,
|
||||
// with nothing in this binary that knows what that subsystem serves.
|
||||
func TestAgentResolvesItsToolsFromTheFleetDoor(t *testing.T) {
|
||||
fleetDoor(t, map[string]string{
|
||||
"alpha": subsystem(t, "alpha", "alpha_echo", "alpha_other"),
|
||||
"beta": subsystem(t, "beta", "beta_echo"),
|
||||
})
|
||||
|
||||
door := doorTools{}
|
||||
defs := door.catalog(context.Background(), "acme", "acme/u-1", []string{"alpha_echo", "beta_echo"})
|
||||
if len(defs) != 2 {
|
||||
t.Fatalf("the agent declared two tools the fleet serves and was offered %d: %+v", len(defs), defs)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, d := range defs {
|
||||
got[d.Name] = true
|
||||
if d.Description == "" {
|
||||
t.Errorf("%s came back with no description, so the model is offered a tool it cannot choose", d.Name)
|
||||
}
|
||||
if len(d.Schema) == 0 {
|
||||
t.Errorf("%s came back with no schema, so the model cannot fill its arguments", d.Name)
|
||||
}
|
||||
}
|
||||
if !got["alpha_echo"] || !got["beta_echo"] {
|
||||
t.Fatalf("offered %v, want alpha_echo and beta_echo", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A declared name NOTHING in the fleet serves is absent, never offered. Offering
|
||||
// a tool that would be refused at dispatch teaches the model a lie.
|
||||
func TestUnservedNamesAreNotOffered(t *testing.T) {
|
||||
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")})
|
||||
|
||||
door := doorTools{}
|
||||
defs := door.catalog(context.Background(), "acme", "acme/u-1",
|
||||
[]string{"alpha_echo", "slack_post_message"})
|
||||
if len(defs) != 1 || defs[0].Name != "alpha_echo" {
|
||||
t.Fatalf("offered %+v, want alpha_echo alone", defs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheAgentInheritsTheDoorsDenylist is the security bar, as a test.
|
||||
//
|
||||
// The curation rule lives in fleet/surface.go and is applied inside gather,
|
||||
// where the routing table is written. An agent reaching the door through any
|
||||
// other path would have seen a surface external MCP clients cannot — so this
|
||||
// asserts BOTH halves: the credential-minting op is not offered, and naming it
|
||||
// anyway does not run it.
|
||||
func TestTheAgentInheritsTheDoorsDenylist(t *testing.T) {
|
||||
fleetDoor(t, map[string]string{
|
||||
"iam": subsystem(t, "iam", "CreateServiceAccountKey", "GetRole"),
|
||||
})
|
||||
door := doorTools{}
|
||||
ctx := context.Background()
|
||||
|
||||
defs := door.catalog(ctx, "acme", "acme/u-1", []string{"CreateServiceAccountKey", "GetRole"})
|
||||
if len(defs) != 1 || defs[0].Name != "GetRole" {
|
||||
t.Fatalf("the agent was offered %+v; the door projects GetRole and refuses CreateServiceAccountKey", defs)
|
||||
}
|
||||
if _, err := door.call(ctx, "acme", "acme/u-1", "CreateServiceAccountKey", `{"say":"hi"}`); err == nil {
|
||||
t.Fatal("a refused tool RAN for an agent that named it directly — the denylist is a suggestion, not a boundary")
|
||||
}
|
||||
}
|
||||
|
||||
// TestADispatchCarriesTheRunsOrg: the tenant reaches the subsystem that owns the
|
||||
// tool, and it comes from the run rather than from anything the model emitted.
|
||||
func TestADispatchCarriesTheRunsOrg(t *testing.T) {
|
||||
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")})
|
||||
|
||||
door := doorTools{}
|
||||
out, err := door.call(context.Background(), "acme", "acme/u-1", "alpha_echo", `{"say":"pong"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"app":"alpha"`, `"say":"pong"`, `"org":"acme"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("alpha answered %q, which does not carry %s", out, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A tool the door cannot route is an ERROR the model reads, never a silent empty
|
||||
// result and never a killed turn.
|
||||
func TestAnUnroutableToolIsAnErrorNotAnEmptyResult(t *testing.T) {
|
||||
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")})
|
||||
|
||||
door := doorTools{}
|
||||
out, err := door.call(context.Background(), "acme", "acme/u-1", "nobody_serves_this", `{}`)
|
||||
if err == nil {
|
||||
t.Fatalf("an unroutable tool answered %q with no error", out)
|
||||
}
|
||||
if got := dispatchOne(context.Background(), "acme", "acme/u-1",
|
||||
types.ToolCall{ID: "c1", Name: "nobody_serves_this", Arguments: `{}`}); !strings.Contains(got, "error:") {
|
||||
t.Fatalf("the model was handed %q for a tool that cannot run", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Arguments that are not a JSON object are refused HERE, before the wire, and
|
||||
// the model is told so — the same sentence the co-resident plane gives it.
|
||||
func TestMalformedArgumentsNeverReachTheDoor(t *testing.T) {
|
||||
fleetDoor(t, map[string]string{"alpha": subsystem(t, "alpha", "alpha_echo")})
|
||||
|
||||
door := doorTools{}
|
||||
if _, err := door.call(context.Background(), "acme", "acme/u-1", "alpha_echo", `["not","an","object"]`); err == nil {
|
||||
t.Fatal("a JSON array was accepted as a tool's arguments")
|
||||
}
|
||||
}
|
||||
|
||||
// With no router on this host, this process IS the fleet: doorTools falls back
|
||||
// to the in-process registry rather than reporting an outage — and a run in a
|
||||
// single-app binary keeps working instead of crashing.
|
||||
func TestNoFleetDoorFallsBackToThisProcesssRegistry(t *testing.T) {
|
||||
noFleetDoor(t)
|
||||
door := doorTools{}
|
||||
ctx := context.Background()
|
||||
|
||||
if defs := door.catalog(ctx, "acme", "acme/u-1", []string{"alpha_echo"}); len(defs) != 0 {
|
||||
t.Fatalf("this process serves no such tool, so nothing may be offered: %+v", defs)
|
||||
}
|
||||
if _, err := door.call(ctx, "acme", "acme/u-1", "alpha_echo", `{}`); err == nil {
|
||||
t.Fatal("a tool nothing in this process registers reported success")
|
||||
}
|
||||
}
|
||||
+108
-2
@@ -2,6 +2,9 @@ package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -34,6 +37,18 @@ func RunOnBehalf(ctx context.Context, org, userSub, ref, input string) (Run, err
|
||||
}
|
||||
|
||||
func runOnBehalf(s *cloud.Service[state], ctx context.Context, org, userSub, ref, input string) (Run, error) {
|
||||
return runOnBehalfModel(s, ctx, org, userSub, ref, input, "")
|
||||
}
|
||||
|
||||
// runOnBehalfModel is runOnBehalf with the ASKER's model preference.
|
||||
//
|
||||
// It overrides the agent's own Model only when the caller named one AND the
|
||||
// agent is the built-in default — a person's Slack preference must not silently
|
||||
// re-point an agent their org deliberately configured. An unrecognised value is
|
||||
// ignored rather than forwarded: the menu came from us, so anything else is a
|
||||
// stale client or a forged payload, and it would bill this org for a model it
|
||||
// never offered.
|
||||
func runOnBehalfModel(s *cloud.Service[state], ctx context.Context, org, userSub, ref, input, model string) (Run, error) {
|
||||
org = strings.TrimSpace(org)
|
||||
if org == "" || len(org) > principal.MaxOrgLen {
|
||||
return Run{}, fmt.Errorf("agents: invalid org")
|
||||
@@ -49,14 +64,105 @@ func runOnBehalf(s *cloud.Service[state], ctx context.Context, org, userSub, ref
|
||||
return Run{}, err
|
||||
}
|
||||
a, err := sto.Resolve(ctx, org, strings.TrimSpace(ref))
|
||||
if err != nil {
|
||||
return Run{}, err // errNotFound or a real DB error — caller replies generically
|
||||
if errors.Is(err, errNotFound) {
|
||||
// An org that has never opened the agents UI has NO rows, and the chat
|
||||
// bridges ask for the conventional ref ("hanzo") — so @hanzo answered
|
||||
// "the agent hit an error handling that" in every workspace that connected
|
||||
// Slack and did nothing else. Measured: `agents: agent not found`, for the
|
||||
// org that had just linked successfully.
|
||||
//
|
||||
// The conventional ref therefore resolves to a BUILT-IN default rather than
|
||||
// requiring an org to create a row before the front door works. It is not
|
||||
// persisted: writing a row here would fork the definition per org and make
|
||||
// a later product change unable to reach the orgs that had already been
|
||||
// seeded. A row the org DOES create wins, because Resolve is tried first.
|
||||
if def, ok := builtinAgent(org, ref, builtinAgentModel()); ok {
|
||||
a = def
|
||||
} else {
|
||||
return Run{}, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return Run{}, err // a real DB error — caller replies generically
|
||||
}
|
||||
// The actor attributes the spend to the acting principal (org/userSub) for the
|
||||
// audit trail; the BALANCE gated + debited is always a.Org (== org), never the
|
||||
// caller. Synthetic request id: in-process, there is no HTTP X-Request-Id; the
|
||||
// client IP is empty (no socket).
|
||||
if m := strings.TrimSpace(model); m != "" && strings.HasPrefix(a.ID, "builtin-") && knownChatModel(m) {
|
||||
a.Model = m
|
||||
}
|
||||
actor := billingActor(org, userSub)
|
||||
reqID, _ := genID("obh")
|
||||
return runAgent(s, ctx, a, input, actor, reqID, "")
|
||||
}
|
||||
|
||||
// builtinAgent is the definition the conventional chat ref resolves to when an
|
||||
// org has not defined its own.
|
||||
//
|
||||
// ONE name, the convention the bridges already default to (bridgeAgentRef →
|
||||
// "hanzo"). Anything else is a real miss and stays a miss: an unknown ref must
|
||||
// not silently become the default agent, or a typo in `code: repo` would run the
|
||||
// chat agent and look like it worked.
|
||||
//
|
||||
// The model is enso, Hanzo's own auto-routing SKU, which picks the right model
|
||||
// per query server-side. NOT cloud.FallbackModel ("best"): that constant's own
|
||||
// doc says it "keeps a bot's reply landing when the flash tier is saturated; the
|
||||
// interactive chat path never uses it" — it is the degraded path, and a Slack
|
||||
// turn IS the interactive chat path. Studio names the same default for the same
|
||||
// reason (middleware/studio_home.py: STUDIO_CHAT_MODEL or "enso").
|
||||
//
|
||||
// Tools is deliberately EMPTY. The tool-calling loop is what decides what an
|
||||
// agent may reach, and handing the default agent a tool set here would be
|
||||
// deciding that in the wrong place.
|
||||
func builtinAgent(org, ref, model string) (Agent, bool) {
|
||||
if !strings.EqualFold(strings.TrimSpace(ref), builtinAgentName) {
|
||||
return Agent{}, false
|
||||
}
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return Agent{}, false // no model configured: an honest miss, not a broken run
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
return Agent{
|
||||
ID: "builtin-" + builtinAgentName, Org: org, Name: builtinAgentName, Model: model,
|
||||
Instructions: builtinAgentInstructions,
|
||||
Description: "The default Hanzo assistant that answers in chat.",
|
||||
Status: "ready", ExecutionMode: ModeOneShot,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}, true
|
||||
}
|
||||
|
||||
const builtinAgentName = "hanzo"
|
||||
|
||||
// builtinAgentInstructions is what the default assistant is TOLD it is. Kept
|
||||
// short on purpose: a long persona spends context a user's actual question needs,
|
||||
// and every sentence here is one the model reads on every turn.
|
||||
const builtinAgentInstructions = "You are Hanzo, the assistant for the Hanzo cloud. " +
|
||||
"Answer in Slack: be brief, concrete, and say plainly when you do not know or " +
|
||||
"cannot reach something rather than guessing."
|
||||
|
||||
// builtinAgentModel is the chat brain: enso, the auto-routing SKU that selects
|
||||
// per query in the gateway's own catalog, overridable per deployment.
|
||||
//
|
||||
// Forwarded verbatim and never validated here — the catalog (hanzoai/ai
|
||||
// conf/models.yaml) is what resolves and routes it, and a check in cloud could
|
||||
// only ever disagree with the thing that actually decides.
|
||||
func builtinAgentModel() string {
|
||||
if v := strings.TrimSpace(os.Getenv("BRIDGE_AGENT_MODEL")); v != "" {
|
||||
return v
|
||||
}
|
||||
return "enso"
|
||||
}
|
||||
|
||||
// knownChatModel accepts only a model this deployment offers for chat.
|
||||
//
|
||||
// The enso family is the auto-routing SKU set the App Home menu is built from.
|
||||
// Anything else is refused rather than forwarded — an arbitrary string from a
|
||||
// client would let a caller pick what their org pays for.
|
||||
func knownChatModel(m string) bool {
|
||||
switch m {
|
||||
case "enso", "enso-flash", "enso-ultra":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,25 @@ func planeRunOnBehalf(ctx context.Context, in *plane.RunOnBehalfIn) (*plane.RunO
|
||||
if strings.TrimSpace(in.Subject) == "" {
|
||||
return nil, fmt.Errorf("agents: run-on-behalf requires a linked subject")
|
||||
}
|
||||
run, err := runOnBehalf(mounted, ctx, in.Org, in.Subject, in.Ref, in.Input)
|
||||
// The tenant this run bills is NOT stated here, and the reason is worth writing
|
||||
// down because the obvious fix is wrong and was shipped once.
|
||||
//
|
||||
// A run bills: the balance gate is a plane call to commerce, which takes the org
|
||||
// from the CALLER's identity and never from an argument (balance_rpc.go:36), so
|
||||
// no caller can name the books it charges. It is tempting to satisfy that with
|
||||
// cloud.For(ctx, in.Org) right here. That is a NO-OP. This op is reached over the
|
||||
// plane, which is a real request, and zip reads a STATED caller only where there
|
||||
// is NO request (caller.go:352-356) — otherwise CallerOf reads the request's own
|
||||
// headers. The statement is silently discarded and the gate still answers
|
||||
// `authorize: no org on the call`. That is exactly what production did.
|
||||
//
|
||||
// The org must therefore be on the WIRE, stated by the dispatcher on a detached
|
||||
// context before the hop (Caller.headers renders it, caller.go:302). The bridge
|
||||
// does that — see the cloud.For(context.Background(), org) at the plane.Ask in
|
||||
// apps/integrations/bridge.go. By the time we are here it has already arrived as
|
||||
// a header and rides onward for free. in.Org remains in the payload because the
|
||||
// run RECORD needs it; it is not what authorizes the spend.
|
||||
run, err := runOnBehalfModel(mounted, ctx, in.Org, in.Subject, in.Ref, in.Input, in.Model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
package agents
|
||||
|
||||
// tools.go is the part of a run that was missing: Agent.Tools was stored,
|
||||
// updated and shown, and the run never read it. An agent with "slack_post_message"
|
||||
// in its tool list ran one chat completion against a model that had never been
|
||||
// told the tool exists, so every @hanzo turn was a chatbot with no hands.
|
||||
//
|
||||
// A tool call is a CONVERSATION, not a call: the model asks for a tool, something
|
||||
// runs it, the result goes back, and the model decides again. Three things make
|
||||
// that loop safe to run on someone else's money —
|
||||
//
|
||||
// BOUNDED maxToolRounds model turns and one wall-clock budget for the whole
|
||||
// run. The last turn is offered NO tools, so the loop cannot end in
|
||||
// anything but words.
|
||||
// ATTRIBUTED every dispatch carries the run's own (org, actor) — the same pair
|
||||
// the run's fee is billed under — so a tool runs as the principal
|
||||
// that asked for it and the tool plane meters it there.
|
||||
// RECOVERABLE a tool that fails is reported TO THE MODEL as a tool result, not
|
||||
// raised. A broken connector makes the agent explain itself; it does
|
||||
// not kill the turn.
|
||||
//
|
||||
// An agent with an empty Tools list never enters any of this: executeRun takes
|
||||
// the same single completion it always did.
|
||||
//
|
||||
// ── WHERE THE TOOLS COME FROM ─────────────────────────────────────────────────
|
||||
//
|
||||
// A PLUGIN IS A PROCESS. `agents` ships as its own binary (plugin/agents/main.go)
|
||||
// and `tools` as another (manifest/apps.go), so tools.Default() HERE holds only
|
||||
// what agents itself registered — the agentToolProvider at agents.go:399 — and
|
||||
// its activation store is nil, which makes ActivationStore.IsActivated report
|
||||
// false for everything (apps/tools/activation.go:83) and Registry.Dispatch
|
||||
// refuse every name. In the split fleet this process's own registry is not an
|
||||
// answer; it is a fact about this process.
|
||||
//
|
||||
// The thing that CAN answer already exists, and it was already deployed: the
|
||||
// fleet's composed agent door (fleet/mcp.go), which asks every app what it
|
||||
// serves right now, merges the union, and forwards a call to the app that listed
|
||||
// the name. It is what api.hanzo.ai/v1/mcp is. So there is one tool surface in
|
||||
// this fleet and an agent reads THAT one — door.go is the client, over the
|
||||
// host's own socket, and it is the plane a real deployment uses.
|
||||
//
|
||||
// registryTools stays as what this process's own registry says, which is the
|
||||
// whole answer exactly where this process is the whole fleet: a single-app
|
||||
// binary, a dev box, a test. doorTools falls back to it there and nowhere else,
|
||||
// on the one signal that means it — nothing listening on the router's socket.
|
||||
//
|
||||
// The degradation that remains is an OUTAGE, and it is visible: every run's step
|
||||
// span carries both hanzo.agent.tools_declared and hanzo.agent.tools, so
|
||||
// "declared 3, offered 0" is a number in o11y rather than a silence, and the
|
||||
// door's own error is recorded beside it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/apps/tools"
|
||||
"github.com/hanzoai/cloud/types"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxToolRounds is how many times the model may ask for tools in one run.
|
||||
// A loop is only as safe as its bound: each round is a completion the org
|
||||
// pays for, and a model that has decided to call the same tool forever will
|
||||
// do exactly that. Eight is deep enough for read-then-act-then-confirm and
|
||||
// shallow enough that a wedged agent costs a known amount.
|
||||
maxToolRounds = 8
|
||||
// toolRunBudget is the wall clock for the WHOLE loop, tools included. A Slack
|
||||
// turn is waiting on this, and a caller carrying a tighter deadline still
|
||||
// wins — this is a ceiling, never an extension.
|
||||
toolRunBudget = 90 * time.Second
|
||||
// toolCallTimeout bounds ONE dispatch, so a single hung connector cannot eat
|
||||
// the whole run's budget and starve the turn of its answer.
|
||||
toolCallTimeout = 30 * time.Second
|
||||
// maxToolResult bounds what one tool may put back into the transcript. A tool
|
||||
// that returns a megabyte would be paid for as prompt tokens on every
|
||||
// remaining round; the model is told the result was truncated.
|
||||
maxToolResult = 16 * 1024
|
||||
// maxToolArgs bounds the arguments a model may emit for one call, before they
|
||||
// are ever parsed.
|
||||
maxToolArgs = 32 * 1024
|
||||
// maxAgentDepth bounds how deep AGENTS may nest, which is a different bound
|
||||
// from maxToolRounds and is not covered by it: an agent is itself a tool
|
||||
// (agentToolProvider, agents.go:399), so A calling B calling A is a cycle in
|
||||
// which every level gets a FRESH round cap and a fresh fee. Three levels is
|
||||
// an agent delegating to a specialist that delegates once more; deeper than
|
||||
// that is a loop, and at the bottom an agent is simply offered no tools and
|
||||
// has to answer for itself.
|
||||
maxAgentDepth = 3
|
||||
)
|
||||
|
||||
// depthKey carries how many agents deep this run is. Unexported zero-size type,
|
||||
// so nothing outside this package can forge a shallower depth.
|
||||
type depthKey struct{}
|
||||
|
||||
// agentDepth reads the nesting depth off the context; a top-level run is 0.
|
||||
func agentDepth(ctx context.Context) int {
|
||||
d, _ := ctx.Value(depthKey{}).(int)
|
||||
return d
|
||||
}
|
||||
|
||||
// deeper marks the context one agent deeper. It is applied at the DISPATCH, so
|
||||
// the depth travels with the call that creates the nesting — a nested run reads
|
||||
// it from the context its parent's tool call handed it.
|
||||
func deeper(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, depthKey{}, agentDepth(ctx)+1)
|
||||
}
|
||||
|
||||
// callableTools is what an agent may actually be offered: its declared names,
|
||||
// minus the one that would call the agent ITSELF. A self-call is a recursion no
|
||||
// round cap bounds, because each level starts its cap over.
|
||||
func callableTools(a Agent) []string {
|
||||
self := "agent_" + a.Name
|
||||
out := make([]string, 0, len(a.Tools))
|
||||
for _, n := range a.Tools {
|
||||
if strings.TrimSpace(n) == self {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toolPlane is where a run's callable tools come from: what may be offered to
|
||||
// the model, and what happens when it asks for one.
|
||||
//
|
||||
// It is an interface for the reason the package comment gives — the answer is
|
||||
// per-DEPLOYMENT, not per-run — and it is deliberately narrow: names, prose,
|
||||
// schemas, and one call that takes raw JSON in and returns text out. Nothing in
|
||||
// it is a map, which is what let the same shape cross a process boundary
|
||||
// unchanged (door.go) rather than being redesigned at the seam.
|
||||
type toolPlane interface {
|
||||
// catalog resolves the tool NAMES an agent declares into definitions the
|
||||
// model can be offered. A name that resolves to nothing is simply absent —
|
||||
// offering a tool that would be refused at dispatch teaches the model a lie.
|
||||
catalog(ctx context.Context, org, actor string, want []string) []types.ToolDef
|
||||
// call runs one tool as (org, actor) and returns its result as text. args is
|
||||
// the raw JSON object the model emitted, verbatim.
|
||||
call(ctx context.Context, org, actor, name, args string) (string, error)
|
||||
}
|
||||
|
||||
// runTools is the tool plane a run uses: the fleet's own agent door, which
|
||||
// answers with this process's registry wherever this process IS the fleet
|
||||
// (door.go). A package var so a test can substitute a deterministic one; there
|
||||
// is no exported setter, because which plane answers is a property of the
|
||||
// deployment and not something a caller may choose.
|
||||
var runTools toolPlane = doorTools{}
|
||||
|
||||
// registryTools is the tool plane read IN THIS PROCESS: tools.Default(), the same
|
||||
// registry POST /v1/tools/call dispatches through, with the same activation gate,
|
||||
// the same source precedence and the same x402 settlement. It is the whole answer
|
||||
// where the tool plane is co-resident, and it is honest where it is not — the
|
||||
// registry simply offers nothing.
|
||||
type registryTools struct{}
|
||||
|
||||
// catalog keeps a declared name only when the plane offers it to this org AND it
|
||||
// is dispatchable AND it is activated. All three are the conditions dispatch
|
||||
// itself enforces (apps/tools/registry.go:231), so a tool that survives this
|
||||
// filter is one the model can actually call — which is the only kind worth
|
||||
// spending a prompt on.
|
||||
func (registryTools) catalog(ctx context.Context, org, _ string, want []string) []types.ToolDef {
|
||||
if org == "" || len(want) == 0 {
|
||||
return nil
|
||||
}
|
||||
wanted := make(map[string]bool, len(want))
|
||||
for _, n := range want {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
wanted[n] = true
|
||||
}
|
||||
}
|
||||
if len(wanted) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]types.ToolDef, 0, len(wanted))
|
||||
for _, t := range tools.Default().List(ctx, tools.Scope{Org: org}) {
|
||||
if !wanted[t.Name] || !t.Dispatchable || !t.Activated {
|
||||
continue
|
||||
}
|
||||
out = append(out, types.ToolDef{Name: t.Name, Description: t.Description, Schema: t.Schema})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// call dispatches through the registry's ONE policy path, bound to the run's own
|
||||
// principal. The arguments are decoded into a map HERE, at the in-process seam
|
||||
// that requires one, and nowhere else — the map never appears on a type that has
|
||||
// to cross a process boundary.
|
||||
func (registryTools) call(ctx context.Context, org, actor, name, args string) (string, error) {
|
||||
var decoded map[string]any
|
||||
if s := strings.TrimSpace(args); s != "" && s != "null" {
|
||||
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
|
||||
return "", fmt.Errorf("arguments are not a JSON object: %w", err)
|
||||
}
|
||||
}
|
||||
out, err := tools.Default().Dispatch(ctx, tools.Principal{Org: org, User: actorSub(org, actor)}, name, decoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return renderToolResult(out), nil
|
||||
}
|
||||
|
||||
// actorSub reads the user subject back out of the run's "org/sub" billing actor,
|
||||
// so a dispatch runs as the person the run is billed to. A bare org (a scheduled
|
||||
// run, a service token) has no subject and lends none: the org is the authority
|
||||
// either way, and inventing a user would be attributing the call to nobody.
|
||||
func actorSub(org, actor string) string {
|
||||
actor = strings.TrimSpace(actor)
|
||||
if actor == "" || actor == org {
|
||||
return ""
|
||||
}
|
||||
if sub, ok := strings.CutPrefix(actor, org+"/"); ok {
|
||||
return sub
|
||||
}
|
||||
return actor
|
||||
}
|
||||
|
||||
// renderToolResult turns whatever a tool returned into the text the model reads.
|
||||
// A string is already text; anything else is its JSON, which is the shape the
|
||||
// tool's own schema describes. A value that will not marshal is reported as that
|
||||
// fact rather than as an empty result the model would read as success.
|
||||
func renderToolResult(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return truncateToolResult(t)
|
||||
case []byte:
|
||||
return truncateToolResult(string(t))
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "the tool returned a value that could not be encoded"
|
||||
}
|
||||
return truncateToolResult(string(b))
|
||||
}
|
||||
|
||||
// truncateToolResult bounds one tool result and SAYS SO. A silently clipped result is a
|
||||
// result the model believes it read in full.
|
||||
func truncateToolResult(s string) string {
|
||||
if len(s) <= maxToolResult {
|
||||
return s
|
||||
}
|
||||
return s[:maxToolResult] + "\n…[truncated: the result was longer than this agent may read]"
|
||||
}
|
||||
|
||||
// completeWithTools is the loop.
|
||||
//
|
||||
// It runs the conversation forward: complete, run whatever the model asked for,
|
||||
// append the results, complete again — until the model answers in words, the
|
||||
// round budget runs out, or the deadline does. Every completion goes through
|
||||
// completeWithFailover, so the retry-and-fail-over reliability policy the run
|
||||
// path already had applies to EVERY round rather than only the first, and the
|
||||
// model reported back is the one that produced the final answer.
|
||||
//
|
||||
// The last round is offered no tools at all. A cap that simply stopped would
|
||||
// return the model's last tool REQUEST as if it were an answer; offering nothing
|
||||
// forces the model to say what it has, which is a real reply to the person
|
||||
// waiting on it.
|
||||
func completeWithTools(ctx context.Context, ai types.AIClient, org, actor, prompt, model, fallback string, defs []types.ToolDef) (*types.ChatResponse, string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, toolRunBudget)
|
||||
defer cancel()
|
||||
|
||||
msgs := []types.ChatMessage{{Role: types.RoleUser, Content: prompt}}
|
||||
used := model
|
||||
for round := 0; round <= maxToolRounds; round++ {
|
||||
offer := defs
|
||||
if round == maxToolRounds {
|
||||
offer = nil // budget spent — answer in words
|
||||
}
|
||||
resp, m, err := completeWithFailover(ctx, ai,
|
||||
&types.ChatRequest{Model: model, Org: org, Messages: msgs, Tools: offer}, fallback)
|
||||
used = m
|
||||
if err != nil {
|
||||
return nil, used, err
|
||||
}
|
||||
if resp == nil || len(resp.ToolCalls) == 0 || offer == nil {
|
||||
return resp, used, nil
|
||||
}
|
||||
msgs = append(msgs, types.ChatMessage{
|
||||
Role: types.RoleAssistant,
|
||||
Content: resp.Content,
|
||||
ToolCalls: resp.ToolCalls,
|
||||
})
|
||||
for _, tc := range resp.ToolCalls {
|
||||
msgs = append(msgs, types.ChatMessage{
|
||||
Role: types.RoleTool,
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Name,
|
||||
Content: dispatchOne(ctx, org, actor, tc),
|
||||
})
|
||||
}
|
||||
}
|
||||
// Unreachable: the round==maxToolRounds pass returns above whatever the model
|
||||
// does. Stated rather than assumed, so the loop has one exit per outcome.
|
||||
return nil, used, errors.New("agents: tool loop ended without an answer")
|
||||
}
|
||||
|
||||
// dispatchOne runs one tool call and returns the text the model is handed —
|
||||
// SUCCESS OR FAILURE, always as a tool result. A tool that fails is a fact the
|
||||
// model can act on (try another one, or explain), and raising it instead would
|
||||
// throw away a turn the org has already paid for.
|
||||
//
|
||||
// It carries no credential. Tool credentials live in KMS behind the tool plane
|
||||
// and are resolved by the source that owns them, so nothing secret is in scope
|
||||
// here to leak into a transcript: what goes back is the tool's own output or our
|
||||
// own sentence about why there is none.
|
||||
func dispatchOne(ctx context.Context, org, actor string, tc types.ToolCall) string {
|
||||
ctx, span := agentTracer.Start(ctx, "agent.tool", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("gen_ai.tool.name", tc.Name),
|
||||
attribute.String("hanzo.agent.org", org),
|
||||
)
|
||||
|
||||
if len(tc.Arguments) > maxToolArgs {
|
||||
span.SetStatus(codes.Error, "arguments too large")
|
||||
return "error: the arguments for this call were too large to run"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(deeper(ctx), toolCallTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := runTools.call(ctx, org, actor, tc.Name, tc.Arguments)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, "tool call failed")
|
||||
return "error: " + err.Error()
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
// An empty result and a failure look identical to a model reading a blank
|
||||
// string, and only one of them means "it worked".
|
||||
return "(the tool ran and returned nothing)"
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/types"
|
||||
)
|
||||
|
||||
// fakePlane is a deterministic tool plane: it offers exactly what it is given and
|
||||
// records every dispatch, so a test can assert WHAT ran and WHO it ran as.
|
||||
type fakePlane struct {
|
||||
offer []types.ToolDef
|
||||
// calls records (name, args, org, actor) in order.
|
||||
calls []planeCall
|
||||
err error
|
||||
out string
|
||||
}
|
||||
|
||||
type planeCall struct{ name, args, org, actor string }
|
||||
|
||||
func (f *fakePlane) catalog(_ context.Context, org, _ string, want []string) []types.ToolDef {
|
||||
if org == "" || len(want) == 0 {
|
||||
return nil
|
||||
}
|
||||
wanted := map[string]bool{}
|
||||
for _, n := range want {
|
||||
wanted[n] = true
|
||||
}
|
||||
var out []types.ToolDef
|
||||
for _, d := range f.offer {
|
||||
if wanted[d.Name] {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fakePlane) call(_ context.Context, org, actor, name, args string) (string, error) {
|
||||
f.calls = append(f.calls, planeCall{name: name, args: args, org: org, actor: actor})
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
return f.out, nil
|
||||
}
|
||||
|
||||
// withPlane swaps the process tool plane for the duration of one test.
|
||||
func withPlane(t *testing.T, p toolPlane) {
|
||||
t.Helper()
|
||||
prev := runTools
|
||||
runTools = p
|
||||
t.Cleanup(func() { runTools = prev })
|
||||
}
|
||||
|
||||
// scriptAI answers from a script, one entry per completion, and records every
|
||||
// request it was given — which is how a test proves the tools were OFFERED and
|
||||
// the results were fed back.
|
||||
type scriptAI struct {
|
||||
replies []types.ChatResponse
|
||||
seen []types.ChatRequest
|
||||
n int
|
||||
}
|
||||
|
||||
func (s *scriptAI) ChatCompletion(_ context.Context, req *types.ChatRequest) (*types.ChatResponse, error) {
|
||||
cp := *req
|
||||
cp.Messages = append([]types.ChatMessage(nil), req.Messages...)
|
||||
cp.Tools = append([]types.ToolDef(nil), req.Tools...)
|
||||
s.seen = append(s.seen, cp)
|
||||
if s.n >= len(s.replies) {
|
||||
return nil, errors.New("scriptAI: no reply scripted")
|
||||
}
|
||||
r := s.replies[s.n]
|
||||
s.n++
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (s *scriptAI) Embed(_ context.Context, _ *types.EmbedRequest) ([][]float32, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func toolDef(name string) types.ToolDef {
|
||||
return types.ToolDef{Name: name, Description: "d", Schema: json.RawMessage(`{"type":"object"}`)}
|
||||
}
|
||||
|
||||
// A run whose agent declares a tool the plane offers must OFFER it to the model,
|
||||
// EXECUTE what the model asks for, feed the result back, and answer from the
|
||||
// second completion. This is the whole point of the change: before it, Agent.Tools
|
||||
// was never read by the run.
|
||||
func TestRunCallsTools(t *testing.T) {
|
||||
plane := &fakePlane{offer: []types.ToolDef{toolDef("weather")}, out: `{"temp":21}`}
|
||||
withPlane(t, plane)
|
||||
ai := &scriptAI{replies: []types.ChatResponse{
|
||||
{ToolCalls: []types.ToolCall{{ID: "c1", Name: "weather", Arguments: `{"city":"Tokyo"}`}}, FinishReason: "tool_calls"},
|
||||
{Content: "It is 21 degrees in Tokyo."},
|
||||
}}
|
||||
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Tools = []string{"weather"}
|
||||
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "weather in Tokyo?", "")
|
||||
|
||||
if r.Status != "ok" {
|
||||
t.Fatalf("want ok, got %q err=%q", r.Status, r.Error)
|
||||
}
|
||||
if r.Output != "It is 21 degrees in Tokyo." {
|
||||
t.Fatalf("output must be the model's answer AFTER the tool ran, got %q", r.Output)
|
||||
}
|
||||
if len(plane.calls) != 1 {
|
||||
t.Fatalf("want exactly one dispatch, got %d (%+v)", len(plane.calls), plane.calls)
|
||||
}
|
||||
got := plane.calls[0]
|
||||
if got.name != "weather" || got.args != `{"city":"Tokyo"}` {
|
||||
t.Fatalf("dispatch must carry the model's own call, got %+v", got)
|
||||
}
|
||||
if got.org != "maxpower" || got.actor != "maxpower/u1" {
|
||||
t.Fatalf("dispatch must be attributable to the run's org+actor, got %+v", got)
|
||||
}
|
||||
if len(ai.seen) != 2 {
|
||||
t.Fatalf("want two completions (ask, then answer), got %d", len(ai.seen))
|
||||
}
|
||||
if len(ai.seen[0].Tools) != 1 || ai.seen[0].Tools[0].Name != "weather" {
|
||||
t.Fatalf("the first completion must OFFER the declared tool, got %+v", ai.seen[0].Tools)
|
||||
}
|
||||
// The second completion must carry the whole transcript: the user turn, the
|
||||
// assistant's tool call, and the tool result linked back by id.
|
||||
msgs := ai.seen[1].Messages
|
||||
if len(msgs) != 3 {
|
||||
t.Fatalf("want user+assistant+tool in the second turn, got %d: %+v", len(msgs), msgs)
|
||||
}
|
||||
if msgs[1].Role != types.RoleAssistant || len(msgs[1].ToolCalls) != 1 {
|
||||
t.Fatalf("assistant turn must carry its tool calls, got %+v", msgs[1])
|
||||
}
|
||||
if msgs[2].Role != types.RoleTool || msgs[2].ToolCallID != "c1" || msgs[2].Content != `{"temp":21}` {
|
||||
t.Fatalf("tool result must be linked to the call by id, got %+v", msgs[2])
|
||||
}
|
||||
}
|
||||
|
||||
// An agent with no tools — or one whose declared names the plane does not offer —
|
||||
// must behave EXACTLY as before: one completion, a flat prompt, no tools field.
|
||||
func TestRunWithoutToolsIsUnchanged(t *testing.T) {
|
||||
plane := &fakePlane{} // offers nothing
|
||||
withPlane(t, plane)
|
||||
ai := &scriptAI{replies: []types.ChatResponse{{Content: "hi there"}}}
|
||||
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Instructions = "You are a greeter."
|
||||
a.Tools = []string{"weather"} // declared, but the plane offers nothing
|
||||
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "say hi", "")
|
||||
|
||||
if r.Status != "ok" || r.Output != "hi there" {
|
||||
t.Fatalf("want the plain completion, got %+v", r)
|
||||
}
|
||||
if len(ai.seen) != 1 {
|
||||
t.Fatalf("want exactly one completion, got %d", len(ai.seen))
|
||||
}
|
||||
if len(ai.seen[0].Tools) != 0 || len(ai.seen[0].Messages) != 0 {
|
||||
t.Fatalf("no-tools path must send the flat prompt and no tools, got %+v", ai.seen[0])
|
||||
}
|
||||
if ai.seen[0].Prompt != "You are a greeter.\n\nsay hi" {
|
||||
t.Fatalf("prompt must compose instructions + input, got %q", ai.seen[0].Prompt)
|
||||
}
|
||||
if len(plane.calls) != 0 {
|
||||
t.Fatalf("nothing may be dispatched, got %+v", plane.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A tool that fails must NOT kill the turn: the error goes back to the model as
|
||||
// the tool's result, and the model still answers.
|
||||
func TestToolFailureReachesTheModel(t *testing.T) {
|
||||
plane := &fakePlane{offer: []types.ToolDef{toolDef("weather")}, err: errors.New("connector offline")}
|
||||
withPlane(t, plane)
|
||||
ai := &scriptAI{replies: []types.ChatResponse{
|
||||
{ToolCalls: []types.ToolCall{{ID: "c1", Name: "weather", Arguments: `{}`}}},
|
||||
{Content: "I could not reach the weather service."},
|
||||
}}
|
||||
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Tools = []string{"weather"}
|
||||
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "weather?", "")
|
||||
|
||||
if r.Status != "ok" {
|
||||
t.Fatalf("a failed tool must not fail the run, got %q err=%q", r.Status, r.Error)
|
||||
}
|
||||
if r.Output != "I could not reach the weather service." {
|
||||
t.Fatalf("the model must get to answer, got %q", r.Output)
|
||||
}
|
||||
result := ai.seen[1].Messages[2]
|
||||
if result.Role != types.RoleTool || !strings.Contains(result.Content, "connector offline") {
|
||||
t.Fatalf("the failure must be handed back as the tool result, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// The loop is BOUNDED. A model that only ever asks for tools gets maxToolRounds
|
||||
// tool-bearing turns and then one final turn with NO tools, which is what forces
|
||||
// an answer instead of an unbounded spend.
|
||||
func TestToolLoopIsBounded(t *testing.T) {
|
||||
plane := &fakePlane{offer: []types.ToolDef{toolDef("weather")}, out: "ok"}
|
||||
withPlane(t, plane)
|
||||
always := types.ChatResponse{ToolCalls: []types.ToolCall{{ID: "c", Name: "weather", Arguments: `{}`}}}
|
||||
replies := make([]types.ChatResponse, maxToolRounds)
|
||||
for i := range replies {
|
||||
replies[i] = always
|
||||
}
|
||||
// The final, tool-less turn answers in words.
|
||||
replies = append(replies, types.ChatResponse{Content: "done"})
|
||||
ai := &scriptAI{replies: replies}
|
||||
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Tools = []string{"weather"}
|
||||
r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "go", "")
|
||||
|
||||
if r.Status != "ok" || r.Output != "done" {
|
||||
t.Fatalf("bounded loop must still answer, got %+v", r)
|
||||
}
|
||||
if len(ai.seen) != maxToolRounds+1 {
|
||||
t.Fatalf("want %d completions, got %d", maxToolRounds+1, len(ai.seen))
|
||||
}
|
||||
if len(plane.calls) != maxToolRounds {
|
||||
t.Fatalf("want %d dispatches, got %d", maxToolRounds, len(plane.calls))
|
||||
}
|
||||
if len(ai.seen[maxToolRounds].Tools) != 0 {
|
||||
t.Fatalf("the last turn must be offered NO tools so it has to answer in words")
|
||||
}
|
||||
}
|
||||
|
||||
// The dispatch principal is the run's own actor, and a run with no user subject
|
||||
// (a scheduled run) lends none rather than inventing one.
|
||||
func TestActorSub(t *testing.T) {
|
||||
for _, c := range []struct{ org, actor, want string }{
|
||||
{"acme", "acme/U123", "U123"},
|
||||
{"acme", "acme", ""},
|
||||
{"acme", "", ""},
|
||||
{"acme", "scheduler", "scheduler"},
|
||||
} {
|
||||
if got := actorSub(c.org, c.actor); got != c.want {
|
||||
t.Fatalf("actorSub(%q,%q) = %q, want %q", c.org, c.actor, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An agent is itself a tool, so an agent that declares ITSELF would recurse with
|
||||
// a fresh round cap at every level. It is never offered to itself.
|
||||
func TestAgentIsNeverOfferedItself(t *testing.T) {
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Tools = []string{"agent_greeter", "weather"}
|
||||
got := callableTools(a)
|
||||
if len(got) != 1 || got[0] != "weather" {
|
||||
t.Fatalf("an agent must not be offered itself, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A cycle of agents-as-tools (A → B → A) is bounded by DEPTH, which the round cap
|
||||
// cannot bound: each nested run starts its own. At the limit an agent is offered
|
||||
// no tools at all and has to answer for itself.
|
||||
func TestNestedAgentsAreBoundedByDepth(t *testing.T) {
|
||||
plane := &fakePlane{offer: []types.ToolDef{toolDef("weather")}, out: "ok"}
|
||||
withPlane(t, plane)
|
||||
ai := &scriptAI{replies: []types.ChatResponse{{Content: "at the bottom"}}}
|
||||
|
||||
ctx := context.Background()
|
||||
for i := 0; i < maxAgentDepth; i++ {
|
||||
ctx = deeper(ctx)
|
||||
}
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Tools = []string{"weather"}
|
||||
r := executeRun(ctx, ai, "maxpower", "maxpower/u1", a, "go", "")
|
||||
|
||||
if r.Status != "ok" || r.Output != "at the bottom" {
|
||||
t.Fatalf("a run at the depth limit must still answer, got %+v", r)
|
||||
}
|
||||
if len(ai.seen) != 1 || len(ai.seen[0].Tools) != 0 {
|
||||
t.Fatalf("at the depth limit no tools may be offered, got %d completions %+v", len(ai.seen), ai.seen[0].Tools)
|
||||
}
|
||||
if len(plane.calls) != 0 {
|
||||
t.Fatalf("nothing may be dispatched at the depth limit, got %+v", plane.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A dispatch carries the run one level deeper, which is what makes the depth
|
||||
// bound reachable at all: the nested run reads it off the context it was handed.
|
||||
func TestDispatchDeepensTheContext(t *testing.T) {
|
||||
var saw int
|
||||
withPlane(t, &depthProbe{seen: &saw, offer: []types.ToolDef{toolDef("weather")}})
|
||||
ai := &scriptAI{replies: []types.ChatResponse{
|
||||
{ToolCalls: []types.ToolCall{{ID: "c1", Name: "weather", Arguments: `{}`}}},
|
||||
{Content: "done"},
|
||||
}}
|
||||
a := mk("maxpower", "greeter")
|
||||
a.Tools = []string{"weather"}
|
||||
if r := executeRun(context.Background(), ai, "maxpower", "maxpower/u1", a, "go", ""); r.Status != "ok" {
|
||||
t.Fatalf("run failed: %+v", r)
|
||||
}
|
||||
if saw != 1 {
|
||||
t.Fatalf("a dispatch from a top-level run must be at depth 1, got %d", saw)
|
||||
}
|
||||
}
|
||||
|
||||
// depthProbe records the nesting depth the dispatch context carries.
|
||||
type depthProbe struct {
|
||||
seen *int
|
||||
offer []types.ToolDef
|
||||
}
|
||||
|
||||
func (d *depthProbe) catalog(_ context.Context, org, _ string, want []string) []types.ToolDef {
|
||||
if org == "" || len(want) == 0 {
|
||||
return nil
|
||||
}
|
||||
return d.offer
|
||||
}
|
||||
|
||||
func (d *depthProbe) call(ctx context.Context, _, _, _, _ string) (string, error) {
|
||||
*d.seen = agentDepth(ctx)
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// A tool result longer than the transcript budget is clipped AND SAID to be
|
||||
// clipped — a silently truncated result is one the model believes it read whole.
|
||||
func TestToolResultTruncationIsStated(t *testing.T) {
|
||||
long := strings.Repeat("x", maxToolResult+100)
|
||||
got := renderToolResult(long)
|
||||
if len(got) <= maxToolResult || !strings.Contains(got, "truncated") {
|
||||
t.Fatalf("a clipped result must say so, got %d bytes", len(got))
|
||||
}
|
||||
if s := renderToolResult(map[string]any{"a": 1}); s != `{"a":1}` {
|
||||
t.Fatalf("a structured result must reach the model as its JSON, got %q", s)
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,7 @@ func init() {
|
||||
Description: "Answers a bridge's turn.\n\nUnlike the session ops, the org travels IN the request rather than being taken\nfrom the caller's plane identity: the tenant here is the one that connected the\nSlack workspace, resolved by the bridge from the signed team_id, and the bridge\nplugin's own identity is not it. That is safe because this op only SPENDS the\nnamed org's own balance under its own agent — it reads nothing across tenants —\nand because the subject must be a link the bridge already proved.\n\nAn empty subject is refused rather than defaulted. A turn that lost its caller\nmust not run AS THE ORG: that would bill the tenant for an unattributable act\nand hand an unlinked user the org's agent.",
|
||||
Fields: map[string]string{
|
||||
"RunOnBehalfIn.input": "Input is the user's message, already stripped of the leading @mention.",
|
||||
"RunOnBehalfIn.model": "Model is the ASKER's own choice, empty when they have not made one. It is a\npreference of the person, not a property of the agent, which is why it rides\nthe turn instead of being written into an agent row: two people in one\nworkspace can prefer different models of the same assistant.",
|
||||
"RunOnBehalfIn.org": "Org is the isolation gate, the tenant, and the balance the run bills.",
|
||||
"RunOnBehalfIn.ref": "Ref names the agent to run.",
|
||||
"RunOnBehalfIn.subject": "Subject is the caller's LINKED Hanzo identity, unqualified. Attribution and\nauthorization both hang off it, so a turn can never run as nobody: the\nanswering side refuses an empty subject rather than falling back to the org.",
|
||||
|
||||
+200
-56
@@ -2,76 +2,220 @@ package coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/apps/agents"
|
||||
"github.com/hanzoai/cloud/apps/tracker"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
)
|
||||
|
||||
// adapters.go binds the session + tracker seams to the real in-process packages.
|
||||
// This is the ONLY file in clients/coding that imports agents/tracker; coding.go
|
||||
// stays pure so the orchestration is unit-tested against fakes. Neither agents nor
|
||||
// tracker imports clients/git or clients/integrations, so these imports are
|
||||
// cycle-free. The third seam, Runner, is bound in task.go — coding's own wire
|
||||
// contract with the bot runtime.
|
||||
// adapters.go binds coding's seams to the apps that OWN them — across the
|
||||
// process boundary, because that is where they are.
|
||||
//
|
||||
// It used to bind them to agents and tracker in-process, which was right while
|
||||
// one binary held every subsystem. It is not right now: each app is its own
|
||||
// process, and every one of those calls reads the callee's `mounted` package
|
||||
// global. A package global is per-PROCESS, so in the process that runs a coding
|
||||
// run they are all nil and each seam answered its zero value — "tracker: not
|
||||
// mounted", an empty clone URL the dispatcher reads as "git is not available",
|
||||
// and a VerifyRef that reports every pushed branch absent and fails the run
|
||||
// closed with no PR. The chat turn died of exactly this shape one file over.
|
||||
//
|
||||
// The orchestration in coding.go is untouched. It always reached its
|
||||
// collaborators through injected seams, which is what makes this a re-binding
|
||||
// and not a rewrite: same Dispatcher, same order, same fail-closed rules, the
|
||||
// calls simply land on a socket instead of a nil global.
|
||||
//
|
||||
// The Runner is the exception that proves it: the bot-gateway sandbox was
|
||||
// always an HTTP client (task.go), so it never had a boundary to cross.
|
||||
|
||||
// NewDispatcher assembles the production Dispatcher: sessions on the live agent
|
||||
// registry, PRs on the tracker, the runner on coding's own runtime stub, plus the
|
||||
// two git seams (cloneURL, verifyRef) the composition root passes from clients/git
|
||||
// (which coding cannot import directly). log is the structured logger for
|
||||
// best-effort mirror failures.
|
||||
func NewDispatcher(
|
||||
cloneURL func(org, repo string) string,
|
||||
verifyRef func(ctx context.Context, org, repo, branch string) (string, bool),
|
||||
log func(msg string, kv ...any),
|
||||
) Dispatcher {
|
||||
// seamTimeout bounds ONE seam call. Every seam here is a small read or write —
|
||||
// open a row, append an event, resolve a name — so a call that has not answered
|
||||
// in this long is a wedged peer, not a slow one, and the run gets an honest
|
||||
// error instead of hanging inside a step.
|
||||
//
|
||||
// It sits UNDER the plane transport's own response-read ceiling, which in a
|
||||
// plugin process is zaphttp's 30s default (zap-proto/http client.go: readTimeout
|
||||
// 30s; only the cmd/cloud host re-registers the zap scheme with a longer one,
|
||||
// and a plugin does not link the host). Under it, the deadline that fires is
|
||||
// always this one — the one whose error names the seam — instead of a bare 502
|
||||
// from the wire. It is also why the RUN itself is not a plane call: a 25-minute
|
||||
// coding run cannot be a request, so it stays a bounded goroutine on the trigger
|
||||
// side and only its seams cross.
|
||||
const seamTimeout = 20 * time.Second
|
||||
|
||||
// NewDispatcher assembles the production Dispatcher: every seam a peer call over
|
||||
// the internal plane, the runner on coding's own bot-gateway wire. log is the
|
||||
// structured logger for best-effort mirror failures (nil is fine).
|
||||
//
|
||||
// It also wires the routed completion seam to THIS dispatcher, so the durable
|
||||
// delivery activity verifies the pushed ref, files the PR and closes the session
|
||||
// exactly as the local path does.
|
||||
func NewDispatcher(log func(msg string, kv ...any)) Dispatcher {
|
||||
d := Dispatcher{
|
||||
Sessions: sessionAdapter{},
|
||||
Tracker: trackerAdapter{},
|
||||
Runner: runner{},
|
||||
CloneURL: cloneURL,
|
||||
VerifyRef: verifyRef,
|
||||
Log: log,
|
||||
// #48 route-work: enqueue a routed run on the ONE embedded tasks engine,
|
||||
// gated by the agents liveness check. Both bind to the real in-process
|
||||
// packages; a routed run with no live engine/target fails closed.
|
||||
Route: enqueueRoutedRun,
|
||||
TargetGate: agents.TargetDispatchable,
|
||||
Sessions: planeSessions{},
|
||||
Tracker: planeTracker{},
|
||||
Runner: runner{},
|
||||
CloneURL: planeCloneURL,
|
||||
VerifyRef: planeVerifyRef,
|
||||
Log: log,
|
||||
Route: planeRoute,
|
||||
TargetGate: planeTargetGate,
|
||||
}
|
||||
// #48 completion parity: bind the routed completion seam to THIS dispatcher's
|
||||
// git/tracker/session seams, so the durable delivery activity verifies the
|
||||
// pushed ref, files the PR, and closes the session exactly as the local path
|
||||
// does. The two git functions resolve their state at call time, so binding here
|
||||
// (init, before any run) is safe.
|
||||
setRoutedFinalizer(d.finalizeRoutedDurable)
|
||||
return d
|
||||
}
|
||||
|
||||
// sessionAdapter forwards to the agents in-process session API (inproc.go).
|
||||
type sessionAdapter struct{}
|
||||
|
||||
func (sessionAdapter) Open(ctx context.Context, org, actor, agent, title string) (string, error) {
|
||||
return agents.OpenSession(ctx, org, actor, agent, title)
|
||||
}
|
||||
func (sessionAdapter) OpenOn(ctx context.Context, org, actor, agent, title, target string) (string, error) {
|
||||
return agents.OpenSessionOn(ctx, org, actor, agent, title, target)
|
||||
}
|
||||
func (sessionAdapter) Log(ctx context.Context, org, sessionID, kind, actor string, payload []byte) error {
|
||||
return agents.LogSessionEvent(ctx, org, sessionID, kind, actor, payload)
|
||||
}
|
||||
func (sessionAdapter) Close(ctx context.Context, org, sessionID, status string) error {
|
||||
return agents.CloseSession(ctx, org, sessionID, status)
|
||||
// bounded gives one seam call its own deadline without letting it outlive the
|
||||
// run's. A run already cancelled fails here rather than on the wire.
|
||||
func bounded(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(ctx, seamTimeout)
|
||||
}
|
||||
|
||||
// trackerAdapter forwards to the tracker in-process agent-PR create (agentpr.go).
|
||||
type trackerAdapter struct{}
|
||||
// planeSessions is the live agent-session registry, in the agents process.
|
||||
type planeSessions struct{}
|
||||
|
||||
func (trackerAdapter) CreatePR(ctx context.Context, in PRInput) (PRRef, error) {
|
||||
pr, err := tracker.CreateAgentPR(ctx, tracker.AgentPRInput{
|
||||
Org: in.Org, Project: in.Project, Repo: in.Repo, Base: in.Base,
|
||||
Head: in.Head, Title: in.Title, Body: in.Body, Assignee: in.Assignee,
|
||||
})
|
||||
func (planeSessions) Open(ctx context.Context, org, actor, agent, title string) (string, error) {
|
||||
return planeSessions{}.OpenOn(ctx, org, actor, agent, title, "")
|
||||
}
|
||||
|
||||
// OpenOn opens the session, tagged with the machine a routed run was sent to so
|
||||
// mission-control shows it where it is executing. An empty target is the
|
||||
// ordinary sandbox session — ONE op, because "no machine" is a value of the
|
||||
// target and not a different question.
|
||||
func (planeSessions) OpenOn(ctx context.Context, org, actor, agent, title, target string) (string, error) {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
out, err := plane.Ask[plane.SessionOpenIn, plane.SessionOpened](ctx, agentsApp, plane.AgentsSessionOpen,
|
||||
&plane.SessionOpenIn{Org: org, Actor: actor, Agent: agent, Title: title, Target: target})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out == nil || out.SessionID == "" {
|
||||
return "", fmt.Errorf("coding: agents opened no session")
|
||||
}
|
||||
return out.SessionID, nil
|
||||
}
|
||||
|
||||
func (planeSessions) Log(ctx context.Context, org, sessionID, kind, actor string, payload []byte) error {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
_, err := plane.Ask[plane.SessionEventIn, plane.CodingAck](ctx, agentsApp, plane.AgentsSessionEvent,
|
||||
&plane.SessionEventIn{Org: org, SessionID: sessionID, Kind: kind, Actor: actor, Payload: payload})
|
||||
return err
|
||||
}
|
||||
|
||||
func (planeSessions) Close(ctx context.Context, org, sessionID, status string) error {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
_, err := plane.Ask[plane.SessionCloseIn, plane.CodingAck](ctx, agentsApp, plane.AgentsSessionClose,
|
||||
&plane.SessionCloseIn{Org: org, SessionID: sessionID, Status: status})
|
||||
return err
|
||||
}
|
||||
|
||||
// planeTracker files the native PR work item, in the tracker process.
|
||||
type planeTracker struct{}
|
||||
|
||||
func (planeTracker) CreatePR(ctx context.Context, in PRInput) (PRRef, error) {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
out, err := plane.Ask[plane.AgentPRIn, plane.AgentPROut](ctx, trackerApp, plane.TrackerAgentPR,
|
||||
&plane.AgentPRIn{
|
||||
Org: in.Org, Project: in.Project, Repo: in.Repo, Base: in.Base,
|
||||
Head: in.Head, Title: in.Title, Body: in.Body, Assignee: in.Assignee,
|
||||
})
|
||||
if err != nil {
|
||||
return PRRef{}, err
|
||||
}
|
||||
return PRRef{Identifier: pr.Identifier, ProjectKey: pr.ProjectKey, Number: pr.Number}, nil
|
||||
if out == nil {
|
||||
return PRRef{}, fmt.Errorf("coding: tracker filed no PR")
|
||||
}
|
||||
return PRRef{Identifier: out.Identifier, ProjectKey: out.ProjectKey, Number: out.Number}, nil
|
||||
}
|
||||
|
||||
// planeCloneURL asks git for the org's clone URL. An error is an EMPTY url,
|
||||
// which the dispatcher already reads as "git is not available" and refuses the
|
||||
// run on — the same fail-closed answer the in-process seam gave when git was
|
||||
// absent, so no caller learns a new failure mode.
|
||||
func planeCloneURL(ctx context.Context, org, repo string) string {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
out, err := plane.Ask[plane.RepoRefIn, plane.RepoCloneURL](ctx, gitApp, plane.GitCloneURL,
|
||||
&plane.RepoRefIn{Org: org, Repo: repo})
|
||||
if err != nil || out == nil {
|
||||
return ""
|
||||
}
|
||||
return out.URL
|
||||
}
|
||||
|
||||
// planeVerifyRef is the integrity gate: git reads the tip off its own storage.
|
||||
// An unreachable git is an UNVERIFIABLE ref, which is treated as absent — the
|
||||
// run fails closed and files no PR, rather than trusting the sandbox's claim.
|
||||
func planeVerifyRef(ctx context.Context, org, repo, branch string) (string, bool) {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
out, err := plane.Ask[plane.RefIn, plane.RefTip](ctx, gitApp, plane.GitVerifyRef,
|
||||
&plane.RefIn{Org: org, Repo: repo, Branch: branch})
|
||||
if err != nil || out == nil || !out.Found {
|
||||
return "", false
|
||||
}
|
||||
return out.SHA, true
|
||||
}
|
||||
|
||||
// planeTargetGate is the fail-closed existence+liveness check for a routed run's
|
||||
// machine: it exists in THIS org, is online, and has a live runner.
|
||||
func planeTargetGate(ctx context.Context, org, targetID string) error {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
_, err := plane.Ask[plane.TargetGateIn, plane.CodingAck](ctx, agentsApp, plane.AgentsTargetGate,
|
||||
&plane.TargetGateIn{Org: org, TargetID: targetID})
|
||||
return err
|
||||
}
|
||||
|
||||
// planeRoute hands the routed run to agents to enqueue.
|
||||
//
|
||||
// It does NOT enqueue the workflow here. The durable delivery activity offers
|
||||
// the run to an in-memory mailbox that the machine long-polls through agents'
|
||||
// HTTP surface, so an enqueue in any other process would hand the run to a
|
||||
// mailbox nobody reads and burn the whole budget before failing. The engine and
|
||||
// the mailbox have to be one process; agents is that process.
|
||||
func planeRoute(ctx context.Context, run RoutedRun) error {
|
||||
ctx, cancel := bounded(ctx)
|
||||
defer cancel()
|
||||
_, err := plane.Ask[plane.RouteRunIn, plane.CodingAck](ctx, agentsApp, plane.AgentsRouteRun,
|
||||
&plane.RouteRunIn{
|
||||
Org: run.Org, TargetID: run.TargetID, SessionID: run.SessionID,
|
||||
Repo: run.Repo, Project: run.Project, Base: run.Base, Branch: run.Branch,
|
||||
Prompt: run.Prompt, CloneURL: run.CloneURL, TimeoutSeconds: run.TimeoutSeconds,
|
||||
Actor: run.Actor, AgentRef: run.AgentRef,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Enqueue is the SERVER half of planeRoute, run by the agents process: put the
|
||||
// routed run on the durable engine THERE, where the mailbox the machine polls
|
||||
// lives and where the delivery activity can therefore hand the run over.
|
||||
//
|
||||
// It builds the process's Dispatcher on first use, which is what binds the
|
||||
// routed COMPLETION seam (setRoutedFinalizer): when the machine reports, the
|
||||
// delivery activity has to verify the pushed ref, file the PR and close the
|
||||
// session, and it reaches those seams through that dispatcher. Enqueueing
|
||||
// without it would queue runs whose sessions never close.
|
||||
func Enqueue(ctx context.Context, in plane.RouteRunIn, log func(msg string, kv ...any)) error {
|
||||
enqueueOnce.Do(func() { NewDispatcher(log) })
|
||||
return enqueueRoutedRun(ctx, RoutedRun{
|
||||
Org: in.Org, TargetID: in.TargetID, SessionID: in.SessionID,
|
||||
Repo: in.Repo, Project: in.Project, Base: in.Base, Branch: in.Branch,
|
||||
Prompt: in.Prompt, CloneURL: in.CloneURL, TimeoutSeconds: in.TimeoutSeconds,
|
||||
Actor: in.Actor, AgentRef: in.AgentRef,
|
||||
})
|
||||
}
|
||||
|
||||
var enqueueOnce sync.Once
|
||||
|
||||
// The peers, spelled once.
|
||||
const (
|
||||
agentsApp = "agents"
|
||||
gitApp = "git"
|
||||
trackerApp = "tracker"
|
||||
)
|
||||
|
||||
@@ -184,12 +184,14 @@ const (
|
||||
)
|
||||
|
||||
// Dispatcher wires the seams. The two git functions are injected (not an
|
||||
// interface) because they are pure reads with no cloud-side state.
|
||||
// interface) because they are pure reads with no cloud-side state. Both take a
|
||||
// ctx: git is another PROCESS, so both are calls that can be slow, refused, or
|
||||
// cancelled with the run.
|
||||
type Dispatcher struct {
|
||||
Sessions Sessions
|
||||
Tracker Tracker
|
||||
Runner Runner
|
||||
CloneURL func(org, repo string) string
|
||||
CloneURL func(ctx context.Context, org, repo string) string
|
||||
VerifyRef func(ctx context.Context, org, repo, branch string) (string, bool)
|
||||
// Log is an optional structured log seam for best-effort mirror failures; nil
|
||||
// is fine (mirror failures are non-fatal and simply dropped).
|
||||
@@ -241,7 +243,7 @@ func (d Dispatcher) Run(ctx context.Context, req Req) Result {
|
||||
}
|
||||
cloneURL := ""
|
||||
if d.CloneURL != nil {
|
||||
cloneURL = d.CloneURL(org, repo)
|
||||
cloneURL = d.CloneURL(ctx, org, repo)
|
||||
}
|
||||
if cloneURL == "" {
|
||||
res.Error = "git is not available"
|
||||
@@ -452,7 +454,7 @@ func (d Dispatcher) routed(ctx context.Context, req Req, org, repo, prompt strin
|
||||
// clone URL (non-secret) to hand it.
|
||||
cloneURL := ""
|
||||
if d.CloneURL != nil {
|
||||
cloneURL = d.CloneURL(org, repo)
|
||||
cloneURL = d.CloneURL(ctx, org, repo)
|
||||
}
|
||||
if cloneURL == "" {
|
||||
res.Error = "git is not available"
|
||||
|
||||
@@ -98,7 +98,7 @@ func dispatcherFor(sess *fakeSessions, tr *fakeTracker, run *fakeRunner, verifyO
|
||||
var cloneCalls []string
|
||||
d := Dispatcher{
|
||||
Sessions: sess, Tracker: tr, Runner: run,
|
||||
CloneURL: func(org, repo string) string {
|
||||
CloneURL: func(_ context.Context, org, repo string) string {
|
||||
cloneCalls = append(cloneCalls, org+"/"+repo)
|
||||
return "https://git.test/v1/git/" + org + "/" + repo + ".git"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package coding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// plane_test.go runs a whole coding job through REAL SOCKETS.
|
||||
//
|
||||
// The seam fakes elsewhere in this package call the Dispatcher's fields
|
||||
// directly, which is the right shape for testing the orchestration and the
|
||||
// wrong shape for testing this: the bug being fixed here was never in the
|
||||
// orchestration. It was that each seam landed on a package global belonging to
|
||||
// another PROCESS, and no in-process test can fail on that — the calls all
|
||||
// resolve, against the nil that ships.
|
||||
//
|
||||
// So the peers here are actual zip apps on actual unix sockets, serving the
|
||||
// actual ops the production plugins serve, and the Dispatcher's seams are the
|
||||
// production plane clients. What is proven is what could not be proven before:
|
||||
// every argument ENCODES (a map field would die inside zip.Call, before the
|
||||
// socket — see plane_encodable_test.go), every reply decodes, and a run whose
|
||||
// collaborators are all elsewhere still opens its session, points the sandbox at
|
||||
// its own org, verifies the pushed ref and files its PR.
|
||||
|
||||
// peers stands up the three apps a coding run reaches, each backed by the same
|
||||
// recording fakes the in-process tests use, so an assertion can be made about
|
||||
// what ARRIVED on the far side rather than what was sent.
|
||||
type peers struct {
|
||||
sessions *fakeSessions
|
||||
tracker *fakeTracker
|
||||
clone string
|
||||
tip string
|
||||
found bool
|
||||
gated []string
|
||||
}
|
||||
|
||||
func servePeers(t *testing.T, p *peers) {
|
||||
t.Helper()
|
||||
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir())
|
||||
|
||||
agentsApp := zip.New(zip.Config{AppName: "agents", DisableStartupMessage: true})
|
||||
zip.Post[plane.SessionOpenIn, plane.SessionOpened](agentsApp, "/agents/session/open",
|
||||
func(ctx context.Context, in *plane.SessionOpenIn) (*plane.SessionOpened, error) {
|
||||
id, err := p.sessions.OpenOn(ctx, in.Org, in.Actor, in.Agent, in.Title, in.Target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.SessionOpened{SessionID: id}, nil
|
||||
}, zip.WithOperationID(plane.AgentsSessionOpen))
|
||||
zip.Post[plane.SessionEventIn, plane.CodingAck](agentsApp, "/agents/session/event",
|
||||
func(ctx context.Context, in *plane.SessionEventIn) (*plane.CodingAck, error) {
|
||||
if err := p.sessions.Log(ctx, in.Org, in.SessionID, in.Kind, in.Actor, in.Payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.CodingAck{OK: true}, nil
|
||||
}, zip.WithOperationID(plane.AgentsSessionEvent))
|
||||
zip.Post[plane.SessionCloseIn, plane.CodingAck](agentsApp, "/agents/session/close",
|
||||
func(ctx context.Context, in *plane.SessionCloseIn) (*plane.CodingAck, error) {
|
||||
if err := p.sessions.Close(ctx, in.Org, in.SessionID, in.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.CodingAck{OK: true}, nil
|
||||
}, zip.WithOperationID(plane.AgentsSessionClose))
|
||||
zip.Post[plane.TargetGateIn, plane.CodingAck](agentsApp, "/agents/target-gate",
|
||||
func(_ context.Context, in *plane.TargetGateIn) (*plane.CodingAck, error) {
|
||||
p.gated = append(p.gated, in.Org+"/"+in.TargetID)
|
||||
return &plane.CodingAck{OK: true}, nil
|
||||
}, zip.WithOperationID(plane.AgentsTargetGate))
|
||||
|
||||
gitApp := zip.New(zip.Config{AppName: "git", DisableStartupMessage: true})
|
||||
zip.Post[plane.RepoRefIn, plane.RepoCloneURL](gitApp, "/git/clone-url",
|
||||
func(_ context.Context, in *plane.RepoRefIn) (*plane.RepoCloneURL, error) {
|
||||
p.clone = in.Org + "/" + in.Repo
|
||||
return &plane.RepoCloneURL{URL: "https://git.test/v1/git/" + in.Org + "/" + in.Repo + ".git"}, nil
|
||||
}, zip.WithOperationID(plane.GitCloneURL))
|
||||
zip.Post[plane.RefIn, plane.RefTip](gitApp, "/git/verify-ref",
|
||||
func(_ context.Context, _ *plane.RefIn) (*plane.RefTip, error) {
|
||||
return &plane.RefTip{SHA: p.tip, Found: p.found}, nil
|
||||
}, zip.WithOperationID(plane.GitVerifyRef))
|
||||
|
||||
trackerApp := zip.New(zip.Config{AppName: "tracker", DisableStartupMessage: true})
|
||||
zip.Post[plane.AgentPRIn, plane.AgentPROut](trackerApp, "/tracker/agent-pr",
|
||||
func(ctx context.Context, in *plane.AgentPRIn) (*plane.AgentPROut, error) {
|
||||
ref, err := p.tracker.CreatePR(ctx, PRInput{
|
||||
Org: in.Org, Project: in.Project, Repo: in.Repo, Base: in.Base,
|
||||
Head: in.Head, Title: in.Title, Body: in.Body, Assignee: in.Assignee,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.AgentPROut{Identifier: ref.Identifier, ProjectKey: ref.ProjectKey, Number: ref.Number}, nil
|
||||
}, zip.WithOperationID(plane.TrackerAgentPR))
|
||||
|
||||
for name, app := range map[string]*zip.App{"agents": agentsApp, "git": gitApp, "tracker": trackerApp} {
|
||||
app := app
|
||||
plane.Bind()
|
||||
go func(path string) { _ = app.Listen(path) }(zip.SocketPath(name))
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
waitListening(t, name)
|
||||
}
|
||||
}
|
||||
|
||||
func waitListening(t *testing.T, app string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if up, err := plane.Listening(zip.SocketPath(app)); err == nil && up {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("peer %s never came up", app)
|
||||
}
|
||||
|
||||
// planeDispatcher is the production seam set (adapters.go) with only the sandbox
|
||||
// runner faked — the runner was always an HTTP client and never had a boundary.
|
||||
func planeDispatcher(run *fakeRunner) Dispatcher {
|
||||
return Dispatcher{
|
||||
Sessions: planeSessions{}, Tracker: planeTracker{}, Runner: run,
|
||||
CloneURL: planeCloneURL, VerifyRef: planeVerifyRef, TargetGate: planeTargetGate,
|
||||
}
|
||||
}
|
||||
|
||||
// A changed run completes with every seam on the far side of a socket.
|
||||
func TestRun_OverThePlane_CompletesAcrossProcesses(t *testing.T) {
|
||||
p := &peers{
|
||||
sessions: &fakeSessions{id: "sess_abc123def456"},
|
||||
tracker: &fakeTracker{ref: PRRef{Identifier: "API-7", ProjectKey: "API", Number: 7}},
|
||||
tip: "verifiedsha", found: true,
|
||||
}
|
||||
servePeers(t, p)
|
||||
|
||||
run := &fakeRunner{
|
||||
steps: []Step{{Type: "step", Step: "clone", Message: "cloning", Status: "ok"}},
|
||||
result: RunResult{Changed: true, OK: true, CommitSha: "deadbeef", Diffstat: " 1 file changed"},
|
||||
}
|
||||
res := planeDispatcher(run).Run(context.Background(), Req{
|
||||
Org: "acme", UserID: "u_1", AgentRef: "hanzo", Repo: "api",
|
||||
Prompt: "fix the flake", CredUser: "x-access-token", CredToken: "sk-secret",
|
||||
})
|
||||
|
||||
if !res.OK || !res.Verified {
|
||||
t.Fatalf("run must complete over the plane: %+v", res)
|
||||
}
|
||||
if res.PR.Identifier != "API-7" {
|
||||
t.Fatalf("the PR must be filed through tracker's door, got %q", res.PR.Identifier)
|
||||
}
|
||||
if res.CommitSha != "verifiedsha" {
|
||||
t.Fatalf("the tip must come from git's own storage, got %q", res.CommitSha)
|
||||
}
|
||||
// ISOLATION: the sandbox is pointed only at THIS org's namespace, and that
|
||||
// survives the crossing rather than being re-derived on the far side.
|
||||
if p.clone != "acme/api" {
|
||||
t.Fatalf("git was asked for %q, want acme/api", p.clone)
|
||||
}
|
||||
if !strings.Contains(run.gotReq.CloneURL, "/acme/api.git") {
|
||||
t.Fatalf("clone url did not survive the crossing: %q", run.gotReq.CloneURL)
|
||||
}
|
||||
if run.gotReq.CredToken != "sk-secret" {
|
||||
t.Fatal("the credential must reach the sandbox unchanged")
|
||||
}
|
||||
// The session opened, streamed and closed — all three ops, all across.
|
||||
if len(p.sessions.opened) != 1 || p.sessions.opened[0].org != "acme" {
|
||||
t.Fatalf("session open did not arrive: %+v", p.sessions.opened)
|
||||
}
|
||||
if len(p.sessions.closes) != 1 || p.sessions.closes[0].status != statusDone {
|
||||
t.Fatalf("session close did not arrive: %+v", p.sessions.closes)
|
||||
}
|
||||
// The event payload is bytes precisely so its shape can cross; a dropped
|
||||
// payload would leave mission-control with empty events.
|
||||
var sawStarted bool
|
||||
for _, e := range p.sessions.events {
|
||||
if e.kind == kindStatus && strings.Contains(e.payload, `"status":"started"`) {
|
||||
sawStarted = true
|
||||
}
|
||||
}
|
||||
if !sawStarted {
|
||||
t.Fatalf("event payloads did not survive the crossing: %+v", p.sessions.events)
|
||||
}
|
||||
}
|
||||
|
||||
// A branch git cannot see fails the run CLOSED across the boundary too: the
|
||||
// integrity gate is the reason the PR exists, and an unreachable git must not
|
||||
// read as a verified push.
|
||||
func TestRun_OverThePlane_UnverifiedRefFilesNoPR(t *testing.T) {
|
||||
p := &peers{
|
||||
sessions: &fakeSessions{id: "sess_abc123def456"},
|
||||
tracker: &fakeTracker{ref: PRRef{Identifier: "API-8"}},
|
||||
found: false,
|
||||
}
|
||||
servePeers(t, p)
|
||||
|
||||
run := &fakeRunner{result: RunResult{Changed: true, OK: true, CommitSha: "deadbeef"}}
|
||||
res := planeDispatcher(run).Run(context.Background(), Req{
|
||||
Org: "acme", UserID: "u_1", Repo: "api", Prompt: "fix", CredToken: "sk-secret",
|
||||
})
|
||||
|
||||
if res.OK || !strings.Contains(res.Error, "not found in native git") {
|
||||
t.Fatalf("an unverified ref must fail closed, got %+v", res)
|
||||
}
|
||||
if len(p.tracker.inputs) != 0 {
|
||||
t.Fatal("no PR may be filed for a branch git cannot see")
|
||||
}
|
||||
if len(p.sessions.closes) != 1 || p.sessions.closes[0].status != statusError {
|
||||
t.Fatalf("the session must close error: %+v", p.sessions.closes)
|
||||
}
|
||||
}
|
||||
|
||||
// A peer that is not part of the deployment is an honest error, not a run that
|
||||
// proceeds without it. This is the shape the whole path had in production.
|
||||
func TestRun_OverThePlane_MissingPeerFailsHonestly(t *testing.T) {
|
||||
t.Setenv("ZIP_RUNTIME_DIR", t.TempDir()) // nothing listening at all
|
||||
res := planeDispatcher(&fakeRunner{}).Run(context.Background(), Req{
|
||||
Org: "acme", UserID: "u_1", Repo: "api", Prompt: "fix", CredToken: "sk-secret",
|
||||
})
|
||||
if res.OK || res.Error != "git is not available" {
|
||||
t.Fatalf("an absent git must stop the run before it starts, got %+v", res)
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func routedDispatcher(sess *fakeSessions, run *fakeRunner, router *fakeRouter, g
|
||||
var cloneCalls []string
|
||||
d := Dispatcher{
|
||||
Sessions: sess, Tracker: &fakeTracker{}, Runner: run,
|
||||
CloneURL: func(org, repo string) string {
|
||||
CloneURL: func(_ context.Context, org, repo string) string {
|
||||
cloneCalls = append(cloneCalls, org+"/"+repo)
|
||||
return "https://git.test/v1/git/" + org + "/" + repo + ".git"
|
||||
},
|
||||
@@ -221,7 +221,9 @@ func TestRun_RoutedButRoutingUnwired_FailsClosed(t *testing.T) {
|
||||
run := &fakeRunner{}
|
||||
// No Route / TargetGate seams.
|
||||
d := Dispatcher{Sessions: sess, Tracker: &fakeTracker{}, Runner: run,
|
||||
CloneURL: func(org, repo string) string { return "https://git.test/v1/git/" + org + "/" + repo + ".git" }}
|
||||
CloneURL: func(_ context.Context, org, repo string) string {
|
||||
return "https://git.test/v1/git/" + org + "/" + repo + ".git"
|
||||
}}
|
||||
|
||||
res := d.Run(context.Background(), routedReq())
|
||||
if res.OK || !strings.Contains(res.Error, "routing is not available") {
|
||||
@@ -351,11 +353,7 @@ func TestNewDispatcher_WiresRoutedFinalizer(t *testing.T) {
|
||||
prev := routedFinalizer
|
||||
t.Cleanup(func() { routedFinalizer = prev })
|
||||
routedFinalizer = nil
|
||||
_ = NewDispatcher(
|
||||
func(_, _ string) string { return "https://git.test" },
|
||||
func(context.Context, string, string, string) (string, bool) { return "", true },
|
||||
nil,
|
||||
)
|
||||
_ = NewDispatcher(nil)
|
||||
if routedFinalizer == nil {
|
||||
t.Fatal("NewDispatcher must wire the routed completion seam (else routed sessions never close)")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
|
||||
accountclient "github.com/hanzoai/cloud/apps/account"
|
||||
commercebilling "github.com/hanzoai/commerce/api/billing"
|
||||
commercemid "github.com/hanzoai/commerce/middleware"
|
||||
"github.com/hanzoai/commerce/middleware/iammiddleware"
|
||||
)
|
||||
|
||||
// The customer's own ledger reads — transactions, credit balance, and the
|
||||
// billing account (with its members). billing.hanzo.ai calls all four; none was
|
||||
// mounted, so all four answered 404 and the Transactions, Credits, Team and
|
||||
// Settings tabs were permanently empty.
|
||||
//
|
||||
// What makes that failure worth a test is that it was INVISIBLE to the obvious
|
||||
// check. commerce declares these routes itself, on its api.Route() `user` group
|
||||
// (api/billing/handlers.go), so grepping the module finds them wired and the
|
||||
// deployment looks merely stale. It is not: the co-resident embed registers on
|
||||
// the HOST's router and never compiles that table, so a commerce route exists in
|
||||
// production only if Mount names it. This test asserts the naming, which is the
|
||||
// thing that was actually missing — a library-side grep cannot.
|
||||
//
|
||||
// The assertion is 401-not-404, and the distinction is the whole point. Both are
|
||||
// "no data" to a browser, but 404 means the gate never ran and 401 means it ran
|
||||
// and refused. Only the second proves the route reached its middleware.
|
||||
|
||||
// ledgerApp mounts the four reads exactly as Mount does — same middleware, same
|
||||
// order. A test that assembles a different chain proves only that the chain it
|
||||
// invented behaves.
|
||||
func ledgerApp(t *testing.T) *zip.App {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{})
|
||||
app.Get("/v1/billing/transactions",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.ListTransactions,
|
||||
)
|
||||
app.Get("/v1/billing/credit-balance",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.GetCreditBalance,
|
||||
)
|
||||
app.Get("/v1/billing/accounts",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.ListBillingAccounts,
|
||||
)
|
||||
app.Get("/v1/billing/accounts/:id/members",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.ListAccountMembers,
|
||||
)
|
||||
return app
|
||||
}
|
||||
|
||||
// TestLedgerReads_MountedAndFailClosed — each read must be reachable and must
|
||||
// refuse an anonymous caller. 404 is the production bug this replaces: the route
|
||||
// absent, the gate never reached, the tab empty with nothing to explain it.
|
||||
func TestLedgerReads_MountedAndFailClosed(t *testing.T) {
|
||||
app := ledgerApp(t)
|
||||
for _, path := range []string{
|
||||
"/v1/billing/transactions",
|
||||
"/v1/billing/credit-balance",
|
||||
"/v1/billing/accounts",
|
||||
"/v1/billing/accounts/acme/members",
|
||||
} {
|
||||
resp, err := app.Test(httptest.NewRequest("GET", path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 404 {
|
||||
t.Fatalf("GET %s answered 404 — the route is not mounted, so the auth gate never ran", path)
|
||||
}
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("GET %s: want 401 for an anonymous caller, got %d (%s)", path, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLedgerReads_ForgedSubjectIsRefused — the two reads that filter on a
|
||||
// caller-supplied subject key (ListTransactions on ?user, GetCreditBalance on
|
||||
// ?userId) must not answer a request that names someone else. Unpinned, both
|
||||
// return that subject's rows out of the org namespace, which is the leak the
|
||||
// chain exists to close; anonymous, the pin fail-closes before the handler runs,
|
||||
// so a forged subject buys nothing. Asserted against a hostile request rather
|
||||
// than a well-formed one, because a well-formed one cannot fail this way.
|
||||
func TestLedgerReads_ForgedSubjectIsRefused(t *testing.T) {
|
||||
app := ledgerApp(t)
|
||||
for _, path := range []string{
|
||||
"/v1/billing/transactions?user=victim",
|
||||
"/v1/billing/credit-balance?userId=victim",
|
||||
} {
|
||||
resp, err := app.Test(httptest.NewRequest("GET", path, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("GET %s: a forged subject must be refused with 401, got %d (%s)",
|
||||
path, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -795,6 +795,71 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
commercebilling.DeleteSpendAlert,
|
||||
)
|
||||
|
||||
// THE CUSTOMER'S OWN LEDGER — the four reads billing.hanzo.ai's Transactions,
|
||||
// Credits, Team and Settings tabs call, and which NOTHING in this binary served.
|
||||
//
|
||||
// commerce declares them on its api.Route() `user` group (api/billing/handlers.go),
|
||||
// but that route table is never compiled here: the co-resident embed registers on
|
||||
// the HOST's router, so a commerce route reaches production only if this file names
|
||||
// it. The library had the handlers all along — so the symptom read as a stale image
|
||||
// and was not one. Every unnamed route falls through to the account bridge's
|
||||
// /v1/billing/* wildcard, whose allowlist does not include these four, so all four
|
||||
// answered a bare 404 and four tabs of the billing app rendered empty forever.
|
||||
//
|
||||
// THE CHAIN IS GetTier's, and each link earns its place on a MONEY READ:
|
||||
// - IAMTokenRequired resolves the org from the gateway-validated X-User-Id +
|
||||
// X-Org-Id into Locals("organization") — the namespace every handler reads.
|
||||
// It FALLS THROUGH when there is no validated principal rather than refusing.
|
||||
// - PinBillingSubject is therefore both the gate and the IDOR control. It
|
||||
// fail-closes that fall-through with 401 "sign in to view billing" (401, not
|
||||
// 403 — a browser re-authenticates on 401 and merely reports 403), and it
|
||||
// OVERWRITES every billing subject key {user,userId,customerId} with the
|
||||
// caller's own account.Payer subject. That is load-bearing here and not
|
||||
// decoration: ListTransactions filters on ?user and GetCreditBalance on
|
||||
// ?userId, both unpinned client values — an unpinned read returns every
|
||||
// subject's rows in the org namespace. Because the pin SETS the key rather
|
||||
// than merely validating it, the handlers' "required parameter" 400 can never
|
||||
// fire for a real caller, and the subject is exactly the one account.Payer
|
||||
// debits, so a read can never disagree with the wallet it describes.
|
||||
// - TokenRequired (no masks) runs AFTER the pin so the trusted S2S reader still
|
||||
// resolves an org — IAMTokenRequired admits only IAM principals and would
|
||||
// leave GetOrganization unset for a service token, which panics on a nil
|
||||
// Locals assertion. With no masks it passes any authenticated principal, so
|
||||
// the browser path is unchanged.
|
||||
//
|
||||
// accounts/:id/members carries no subject key and guards itself (:id must equal
|
||||
// the resolved org, else 403), but it takes the same chain: one chain for the
|
||||
// family, and the pin is what turns an anonymous call into 401 instead of a panic.
|
||||
// None is typed yet — module work, per the module-handler note.
|
||||
app.Get("/v1/billing/transactions",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.ListTransactions,
|
||||
)
|
||||
app.Get("/v1/billing/credit-balance",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.GetCreditBalance,
|
||||
)
|
||||
app.Get("/v1/billing/accounts",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.ListBillingAccounts,
|
||||
)
|
||||
app.Get("/v1/billing/accounts/:id/members",
|
||||
commercemid.RequestContext(),
|
||||
iammiddleware.IAMTokenRequired(),
|
||||
accountclient.PinBillingSubject(),
|
||||
commercemid.TokenRequired(),
|
||||
commercebilling.ListAccountMembers,
|
||||
)
|
||||
|
||||
// POST /v1/billing/topup/token — the INLINE Square card top-up (the console's
|
||||
// "Billing → Credits → add credits": the Square Web Payments SDK tokenizes the card
|
||||
// IN THE BROWSER → a single-use nonce → this endpoint charges it and credits the
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/hanzoai/cloud/apps/kms"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
)
|
||||
|
||||
// bridge.go is the ONE ChatBridge core: the platform-agnostic @hanzo front-door
|
||||
@@ -188,7 +188,7 @@ func (l *orgLimiter) release(org string) {
|
||||
func runBridgeTurn(s *cloud.Service[state], org string, in Inbound, reply replyFunc) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), bridgeAgentTimeout)
|
||||
defer cancel()
|
||||
text, ephemeral := bridgeReply(s, ctx, org, in.Provider, in.ExternalID, in.User, in.Text)
|
||||
text, ephemeral := bridgeReply(s, org, in.Provider, in.ExternalID, in.User, in.Text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
@@ -204,7 +204,35 @@ func runBridgeTurn(s *cloud.Service[state], org string, in Inbound, reply replyF
|
||||
// URL. ephemeral reports whether the reply is the (sensitive) link prompt — the
|
||||
// caller MUST deliver those to the user only. Every returned string is safe to
|
||||
// post; internal errors are logged (never a token) and surfaced as a terse message.
|
||||
func bridgeReply(s *cloud.Service[state], ctx context.Context, org, provider, externalID, user, text string) (reply string, ephemeral bool) {
|
||||
// bridgeRunContext is the context ONE agent turn runs on: detached from any
|
||||
// request, carrying the tenant it bills, bounded by the turn budget.
|
||||
//
|
||||
// context.Background() is not an accident and is not merely about cancellation.
|
||||
// zip reads a STATED caller only when NO request sits behind the context
|
||||
// (caller.go:352-356) — deliberately, so that stating an identity can never
|
||||
// override an authenticated one. Hand cloud.For the inbound webhook's ctx and the
|
||||
// org is silently dropped: Slack's POST is a request, it carries no Hanzo identity,
|
||||
// and CallerOf reads those empty headers instead. Downstream then answers
|
||||
// `authorize: no org on the call`, which is precisely how this failed in
|
||||
// production. Detaching is what makes the statement readable at all.
|
||||
//
|
||||
// It is not a laundering hole: For supplies a tenant where there is none and cannot
|
||||
// override one, and this org came from OrgForExternalID on a signature-VERIFIED
|
||||
// payload — never a client-supplied field.
|
||||
//
|
||||
// Detaching is independently required: the turn runs in bridgeSpawn's goroutine
|
||||
// while the webhook handler has already answered Slack 200, so on the request ctx
|
||||
// the model call would be cancelled the instant we reply.
|
||||
func bridgeRunContext(org string) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(cloud.For(context.Background(), org), bridgeAgentTimeout)
|
||||
}
|
||||
|
||||
// It takes NO context on purpose. The turn it dispatches must run on a detached,
|
||||
// tenant-stated one (see the call below), and a ctx parameter here is an invitation
|
||||
// to pass the webhook's — which both cancels the run when we answer Slack and
|
||||
// silently discards the org. Removing the parameter is what makes that unavailable
|
||||
// rather than merely discouraged.
|
||||
func bridgeReply(s *cloud.Service[state], org, provider, externalID, user, text string) (reply string, ephemeral bool) {
|
||||
link, say, ephemeral := bridgeIdentity(s, org, provider, externalID, user)
|
||||
if say != "" {
|
||||
return say, ephemeral
|
||||
@@ -219,8 +247,38 @@ func bridgeReply(s *cloud.Service[state], ctx context.Context, org, provider, ex
|
||||
// requirement and answered ErrNoPeer for every deployment that separates them —
|
||||
// which is every real one. It failed the same way for every chat bridge, so the
|
||||
// door belongs on the plane where the boundary is explicit.
|
||||
out, rerr := plane.Ask[plane.RunOnBehalfIn, plane.RunOnBehalfOut](ctx, "agents", plane.AgentsRunOnBehalf,
|
||||
&plane.RunOnBehalfIn{Org: org, Subject: link.Subject, Ref: bridgeAgentRef(provider), Input: text})
|
||||
// Model is the person's OWN choice from the App Home tab, empty when they have
|
||||
// not chosen — the answering side then uses the deployment default. Carried
|
||||
// per turn rather than baked into an agent row, because it is a preference of
|
||||
// the PERSON asking and not a property of the agent.
|
||||
// STATE THE TENANT, on a context with NO REQUEST BEHIND IT. Both halves matter
|
||||
// and both are load-bearing.
|
||||
//
|
||||
// A run bills, and the balance gate is a plane call to commerce, which takes the
|
||||
// org from the CALLER's identity and never from an argument (balance_rpc.go:36)
|
||||
// so that no caller can name the books it charges. The org therefore has to ride
|
||||
// the caller. cloud.For states it — but zip reads a STATED caller only where
|
||||
// there is no request (caller.go:352-356), deliberately, so that stating an
|
||||
// identity can never override an authenticated one. Applied to the inbound
|
||||
// webhook's ctx it is a silent no-op: Slack's POST is a request, it carries no
|
||||
// Hanzo identity, and CallerOf reads those empty headers instead. That is
|
||||
// exactly how this failed in production with `authorize: no org on the call`
|
||||
// AFTER the tenant was supposedly stated one hop later.
|
||||
//
|
||||
// context.Background() is what makes the statement readable, and it is the same
|
||||
// pairing every other background caller uses (commerce/risk.go:190,
|
||||
// x402/peer.go:71). It is not a laundering hole: For cannot override an
|
||||
// authenticated caller, only supply one where none exists, and this org was
|
||||
// resolved from the Slack-verified team_id through the install→org map — never
|
||||
// from a payload field.
|
||||
//
|
||||
// Detaching is independently REQUIRED anyway: the turn runs in bridgeSpawn's
|
||||
// goroutine, and the webhook handler returns 200 to Slack immediately. On the
|
||||
// request ctx the model call would be cancelled the moment we answer Slack.
|
||||
runCtx, cancel := bridgeRunContext(org)
|
||||
defer cancel()
|
||||
out, rerr := plane.Ask[plane.RunOnBehalfIn, plane.RunOnBehalfOut](runCtx, "agents", plane.AgentsRunOnBehalf,
|
||||
&plane.RunOnBehalfIn{Org: org, Subject: link.Subject, Ref: bridgeAgentRef(provider), Input: text, Model: link.Model})
|
||||
run := plane.RunOnBehalfOut{}
|
||||
if out != nil {
|
||||
run = *out
|
||||
@@ -230,6 +288,15 @@ func bridgeReply(s *cloud.Service[state], ctx context.Context, org, provider, ex
|
||||
return "Sorry — the agent hit an error handling that. Please try again.", false
|
||||
}
|
||||
if run.Status != "ok" {
|
||||
// SAID, not just returned. A run that EXECUTED and whose model failed comes
|
||||
// back as a non-"ok" status with a nil error (agents.RunOnBehalf's contract),
|
||||
// so this branch — not the one above — is the one a broken inference path
|
||||
// lands in. It logged nothing, and the whole failure was therefore invisible:
|
||||
// the op answered 200, the bridge posted its generic sentence, and the only
|
||||
// trace of the cause was the run row. That is how a dead model wire survived
|
||||
// a day of looking. The run id is here so the row is findable.
|
||||
s.Log.Warn("bridge: agent run did not succeed", "provider", provider, "org", org,
|
||||
"status", run.Status, "run_id", run.RunID)
|
||||
return "Sorry — the agent hit an error handling that. Please try again.", false
|
||||
}
|
||||
if strings.TrimSpace(run.Output) == "" {
|
||||
@@ -305,6 +372,12 @@ type userLink struct {
|
||||
Subject string `json:"subject"`
|
||||
Org string `json:"org"`
|
||||
Refresh string `json:"refresh"`
|
||||
// Model and Routing are what the person chose on the App Home tab. Both are
|
||||
// OMITEMPTY and both have a working default, so a link written before the Home
|
||||
// tab existed decodes fine and behaves exactly as it did — a preference that
|
||||
// breaks an existing link is not a preference, it is an outage.
|
||||
Model string `json:"model,omitempty"`
|
||||
Routing string `json:"routing,omitempty"`
|
||||
}
|
||||
|
||||
func putUserLink(s *cloud.Service[state], org, provider, extUser string, link userLink) error {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package integrations
|
||||
|
||||
// The tenant a chat turn bills has to travel ON THE WIRE, and these pin the two
|
||||
// halves of why that is not obvious.
|
||||
//
|
||||
// commerce takes the org from the CALLER's identity and never from an argument, so
|
||||
// no caller can name the books it charges. The org therefore rides the caller. But
|
||||
// zip reads a STATED caller only where there is NO request behind the context
|
||||
// (caller.go:352-356) — otherwise CallerOf reads the request's own headers. So
|
||||
// cloud.For applied to an inbound webhook's context is a SILENT NO-OP.
|
||||
//
|
||||
// That is not hypothetical. It shipped: the tenant was stated one hop later, inside
|
||||
// the agents op, on a context that had the plane request behind it. Every Slack
|
||||
// message still died with "authorize: no org on the call", and the statement looked
|
||||
// correct in review because the code read exactly like the working background
|
||||
// callers elsewhere in the tree. The difference is invisible unless you know the
|
||||
// precedence rule — which is what these tests write down.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
)
|
||||
|
||||
// A turn's context must name the tenant, or the balance gate refuses it.
|
||||
func TestRunContextStatesTheTenant(t *testing.T) {
|
||||
ctx, cancel := bridgeRunContext("acme")
|
||||
defer cancel()
|
||||
if got := cloud.Who(ctx).Org; got != "acme" {
|
||||
t.Fatalf("the turn must act for a named tenant, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The statement must be READABLE, which is only true off a request. This is the
|
||||
// regression that shipped: same call, wrong base context, silently no org.
|
||||
func TestStatingOnARequestContextIsANoOp(t *testing.T) {
|
||||
// A background context is the only base a stated caller survives on.
|
||||
if got := cloud.Who(cloud.For(context.Background(), "acme")).Org; got != "acme" {
|
||||
t.Fatalf("stated tenant must be readable off a request, got %q", got)
|
||||
}
|
||||
// bridgeRunContext must not be derivable from a caller-supplied context: it
|
||||
// takes an org and nothing else, so there is no parameter through which the
|
||||
// webhook's request could be threaded back in. If this ever grows a
|
||||
// context.Context argument, the bug returns — the signature IS the guard.
|
||||
var _ func(string) (context.Context, context.CancelFunc) = bridgeRunContext
|
||||
}
|
||||
|
||||
// An empty org states nothing rather than a blank tenant: downstream must refuse on
|
||||
// "no org" rather than bill an account named "".
|
||||
func TestEmptyTenantIsNotStated(t *testing.T) {
|
||||
ctx, cancel := bridgeRunContext("")
|
||||
defer cancel()
|
||||
if got := cloud.Who(ctx).Org; got != "" {
|
||||
t.Errorf("an empty org must not become a tenant, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The turn outlives the webhook reply, so its deadline must come from the turn
|
||||
// budget and not from a request that is already answered.
|
||||
func TestRunContextIsNotAlreadyCancelled(t *testing.T) {
|
||||
ctx, cancel := bridgeRunContext("acme")
|
||||
defer cancel()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("a fresh turn context is already done; the run would be cancelled before it starts")
|
||||
default:
|
||||
}
|
||||
if _, ok := ctx.Deadline(); !ok {
|
||||
t.Error("a turn must be bounded by bridgeAgentTimeout")
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,16 @@ var slackDefaultScopes = []string{
|
||||
"app_mentions:read", "chat:write",
|
||||
"channels:history", "groups:history",
|
||||
"im:history", "im:read", "im:write", "users:read",
|
||||
// The three below are what the app MANIFEST declares, and this list is the
|
||||
// other half of the same install. Slack grants exactly the scopes the consent
|
||||
// URL asks for, so a scope declared in the manifest but omitted here is simply
|
||||
// not granted to anyone who installs through this URL — and the failure is
|
||||
// silent and late: `commands` missing means /hanzo returns dispatch_failed at
|
||||
// first use, long after the install looked successful. Keep this set equal to
|
||||
// the manifest's `oauth_config.scopes.bot`.
|
||||
"channels:read", // resolve channel names for the agent's context
|
||||
"commands", // the /hanzo slash command
|
||||
"team:read", // resolve the workspace this install belongs to
|
||||
// assistant:write is what makes @hanzo a NATIVE agent rather than a plain bot.
|
||||
// Slack's "Add Agents" picker lists only apps that declare the Agents & AI Apps
|
||||
// surface, and that declaration is three parts, all required: this scope, the
|
||||
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/agents"
|
||||
"github.com/hanzoai/cloud/apps/coding"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
)
|
||||
|
||||
// slack_coding.go turns the @hanzo Slack front-door into an ENGINEER: a message
|
||||
@@ -69,19 +70,27 @@ var codingRepoRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
// once in slackBridgeReady.
|
||||
var codingLim *orgLimiter
|
||||
|
||||
// codingDispatcher is the assembled coding orchestrator, injected by the
|
||||
// composition root (which alone can import clients/git for the CloneURL/VerifyRef
|
||||
// seams). Zero until SetCodingDispatcher runs; codingConfigured gates use.
|
||||
// codingDispatcher is the assembled coding orchestrator.
|
||||
//
|
||||
// It used to be INJECTED by the composition root, because coding could not
|
||||
// import git for the clone-url/verify-ref seams without a cycle. Those seams are
|
||||
// peer calls now — coding asks git over the plane — so the reason is gone and so
|
||||
// is the injection: the surface that triggers a coding run builds the thing that
|
||||
// runs it, once, here. One way to do it, and no window in which a deployment is
|
||||
// mounted but the dispatcher is still nil.
|
||||
var (
|
||||
codingOnce sync.Once
|
||||
codingDispatcher coding.Dispatcher
|
||||
codingConfigured bool
|
||||
)
|
||||
|
||||
// SetCodingDispatcher injects the coding orchestrator. Called once at wiring time
|
||||
// by the composition root; production never reassigns it.
|
||||
func SetCodingDispatcher(d coding.Dispatcher) {
|
||||
codingDispatcher = d
|
||||
codingConfigured = true
|
||||
// codingRunner returns the process's one Dispatcher, built on first use. The
|
||||
// logger seam carries coding's best-effort mirror failures into this
|
||||
// subsystem's log rather than dropping them.
|
||||
func codingRunner(s *cloud.Service[state]) coding.Dispatcher {
|
||||
codingOnce.Do(func() {
|
||||
codingDispatcher = coding.NewDispatcher(func(msg string, kv ...any) { s.Log.Warn(msg, kv...) })
|
||||
})
|
||||
return codingDispatcher
|
||||
}
|
||||
|
||||
// codingIntent reports whether a prompt is a coding request and returns the text
|
||||
@@ -177,13 +186,21 @@ func handleSlackCoding(s *cloud.Service[state], ctx context.Context, org, botTok
|
||||
// non-empty reference that resolves to no target in THIS org is an honest error
|
||||
// (never a silent local fallback, never another tenant's machine). The returned
|
||||
// error's message is the user-facing text.
|
||||
//
|
||||
// It ASKS AGENTS over the plane rather than calling agents.ResolveTarget, which
|
||||
// reads that package's `mounted` global. A plugin is a process: the global is nil
|
||||
// here, so the direct call answered "not mounted" for every `on <machine>` and
|
||||
// every routed run died at the parse step — the same failure the chat turn had
|
||||
// until it moved onto the plane. The org still comes from THIS side, resolved
|
||||
// from the Slack-verified team_id, and is never a field the human can set.
|
||||
func resolveCodingTarget(ctx context.Context, org, ref string) (string, error) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return "", nil // no `on <machine>` — the ordinary cloud-sandbox run
|
||||
}
|
||||
t, err := agents.ResolveTarget(ctx, org, ref)
|
||||
if err != nil {
|
||||
t, err := plane.Ask[plane.TargetRefIn, plane.TargetRef](ctx, "agents", plane.AgentsResolveTarget,
|
||||
&plane.TargetRefIn{Org: org, Ref: ref})
|
||||
if err != nil || t == nil || strings.TrimSpace(t.ID) == "" {
|
||||
return "", fmt.Errorf("No linked machine named `%s` — check `hanzo code --serve` is running and the name is right.", slackEscape(ref))
|
||||
}
|
||||
return t.ID, nil
|
||||
@@ -237,9 +254,7 @@ func handleSlackSlashCoding(s *cloud.Service[state], ctx context.Context, org, t
|
||||
// git with its own already-held credential, so the KMS agent-credential fetch is
|
||||
// skipped and a workspace without one can still route to a linked machine.
|
||||
func startCodingJob(s *cloud.Service[state], org, userSub, botToken, channel, threadTS, repo, task, targetID string) (ack string, started bool) {
|
||||
if !codingConfigured {
|
||||
return "Coding tasks aren't enabled on this deployment yet.", false
|
||||
}
|
||||
dispatch := codingRunner(s)
|
||||
var user, token string
|
||||
if strings.TrimSpace(targetID) == "" {
|
||||
var err error
|
||||
@@ -272,7 +287,7 @@ func startCodingJob(s *cloud.Service[state], org, userSub, botToken, channel, th
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), codingTaskTimeout())
|
||||
defer cancel()
|
||||
res := codingDispatcher.Run(ctx, req)
|
||||
res := dispatch.Run(ctx, req)
|
||||
summary, blocks := codingResultCard(s.Brand, org, repo, res)
|
||||
if perr := PostSlackBlocksThread(ctx, botToken, channel, threadTS, summary, blocks); perr != nil {
|
||||
s.Log.Warn("slack coding: result post", "org", org, "repo", repo, "err", perr)
|
||||
|
||||
@@ -90,6 +90,14 @@ func slackEvents(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
c.Header("X-Slack-Signature"), c.Header("X-Slack-Request-Timestamp"), string(raw), 0) {
|
||||
return zip.ErrUnauthorized("bad slack signature")
|
||||
}
|
||||
// Slack posts INTERACTIVITY to the same request URL as events, form-encoded
|
||||
// rather than JSON. It is past the same signature check, so it is equally
|
||||
// trusted; it just is not an Events envelope and routeSlackEvent would ignore
|
||||
// it. Handled here, before routing, because that is where the two encodings
|
||||
// actually diverge.
|
||||
if slackInteractionBody(raw) {
|
||||
return slackHandleInteraction(s, c, raw)
|
||||
}
|
||||
d := routeSlackEvent(raw)
|
||||
switch d.Kind {
|
||||
case slackRouteChallenge:
|
||||
@@ -106,7 +114,7 @@ func slackEvents(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
s.Log.Warn("slack home: no bot token", "org", org, "err", err)
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
if err := slackPublishHome(c.Context(), string(tok), d.User); err != nil {
|
||||
if err := slackPublishHome(s, c.Context(), string(tok), org, d.User); err != nil {
|
||||
// A Home that fails to render is cosmetic — never fail the event, or
|
||||
// Slack retries a view publish it will render identically next open.
|
||||
s.Log.Warn("slack home: publish failed", "org", org, "err", err)
|
||||
@@ -301,7 +309,7 @@ func slackSlashTurn(s *cloud.Service[state], org string, in Inbound, responseURL
|
||||
return
|
||||
}
|
||||
}
|
||||
text, ephemeral := bridgeReply(s, ctx, org, in.Provider, in.ExternalID, in.User, in.Text)
|
||||
text, ephemeral := bridgeReply(s, org, in.Provider, in.ExternalID, in.User, in.Text)
|
||||
slackSlashReply(s, ctx, in, responseURL, text, ephemeral)
|
||||
}
|
||||
|
||||
@@ -638,42 +646,3 @@ func slackReadBody(c *zip.Ctx) []byte {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// slackPublishHome renders the App Home tab for one user.
|
||||
//
|
||||
// Slack fills an unpublished Home with its own "this is still a work in
|
||||
// progress" placeholder, so the choice is not between a Home and no Home — it is
|
||||
// between OUR page and Slack's apology. Any app that turns on home_tab_enabled
|
||||
// and stops there looks half-built to everyone who clicks it.
|
||||
//
|
||||
// Published per user on app_home_opened rather than once at install: the view is
|
||||
// per-user state in Slack's model, and rendering at open means the page reflects
|
||||
// what is true now instead of what was true when the workspace connected.
|
||||
//
|
||||
// The content deliberately answers the two questions someone clicking Home
|
||||
// actually has — what can this do, and what do I type — rather than describing
|
||||
// the product. A Home that reads like a landing page teaches nothing.
|
||||
func slackPublishHome(ctx context.Context, botToken, user string) error {
|
||||
section := func(text string) map[string]any {
|
||||
return map[string]any{"type": "section", "text": map[string]any{"type": "mrkdwn", "text": text}}
|
||||
}
|
||||
view := map[string]any{
|
||||
"type": "home",
|
||||
"blocks": []map[string]any{
|
||||
{"type": "header", "text": map[string]any{"type": "plain_text", "text": "Hanzo AI", "emoji": true}},
|
||||
section("The Open AI Cloud, in Slack. Ask a question, write and ship code, or query your own infrastructure — in a channel with `@Hanzo`, or right here in a DM."),
|
||||
{"type": "divider"},
|
||||
section("*Try asking*\n• `@Hanzo what changed on main today?`\n• `@Hanzo why is my service returning 500s?`\n• `@Hanzo deploy my app and give me the URL`\n• `@Hanzo add a health check to my Go service`"),
|
||||
{"type": "divider"},
|
||||
section("*Two ways to reach it*\n• `@Hanzo` in any channel it has been invited to\n• `/hanzo <your question>` anywhere, without inviting it"),
|
||||
section("_Hanzo only posts in channels it is a member of — invite it with_ `/invite @Hanzo`_. That is deliberate: it holds no permission to post anywhere uninvited._"),
|
||||
{"type": "context", "elements": []map[string]any{
|
||||
{"type": "mrkdwn", "text": "<https://hanzo.ai|hanzo.ai> · <https://docs.hanzo.ai|Docs> · <https://cloud.hanzo.ai|Console>"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
return slackChatPost(ctx, botToken, "/views.publish", map[string]any{
|
||||
"user_id": user,
|
||||
"view": view,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// Copyright 2023-2026 Hanzo AI Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package integrations
|
||||
|
||||
// slack_home.go is the App Home tab: what a person sees when they click Hanzo in
|
||||
// Slack, and the two settings they can change there.
|
||||
//
|
||||
// Slack fills an UNPUBLISHED Home with its own "this is still a work in
|
||||
// progress" placeholder, so the choice was never Home vs no Home — it was our
|
||||
// page vs Slack's apology. Any app that enables home_tab_enabled and stops there
|
||||
// looks half-built to everyone who opens it.
|
||||
//
|
||||
// The view is published PER USER on app_home_opened rather than once at install,
|
||||
// which is how Slack models it: the page then reflects what is true now — this
|
||||
// person's link, their model, their routing — instead of what was true when the
|
||||
// workspace connected.
|
||||
//
|
||||
// The two controls write to the SAME userLink the chat path reads. There is no
|
||||
// second settings store and no third source of truth for which model answers: a
|
||||
// turn reads the link, the Home writes the link.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
zip "github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// The two action ids the Home's controls carry. Slack echoes these back in the
|
||||
// block_actions payload, so they ARE the routing key for a setting change and
|
||||
// are worth naming once.
|
||||
const (
|
||||
homeActionModel = "hanzo_model"
|
||||
homeActionRouting = "hanzo_routing"
|
||||
)
|
||||
|
||||
// Routing modes. `chat` answers; `code` additionally lets a message start a
|
||||
// coding run. Default is chat: a person who has changed nothing must never have
|
||||
// a message they meant as a question spawn a sandbox and push a branch.
|
||||
const (
|
||||
routingChat = "chat"
|
||||
routingCode = "code"
|
||||
)
|
||||
|
||||
// homeModels is the model menu. The enso family is Hanzo's own auto-routing SKU
|
||||
// set — `enso` picks per query in the gateway's catalog, so it is the default
|
||||
// and the honest recommendation; the other two are the explicit fast/strong
|
||||
// ends for someone who wants to pin one.
|
||||
//
|
||||
// Named here rather than fetched from the catalog because this is a MENU, not an
|
||||
// inventory: /v1/models lists 107 entries and a dropdown of 107 is not a choice,
|
||||
// it is a search problem. A deployment that wants different options changes this
|
||||
// list, and the value is forwarded verbatim either way.
|
||||
var homeModels = []struct{ Value, Label, Note string }{
|
||||
{"enso", "Enso — auto", "Picks the right model for each message"},
|
||||
{"enso-flash", "Enso Flash", "Fastest, for quick questions"},
|
||||
{"enso-ultra", "Enso Ultra", "Strongest, for hard problems"},
|
||||
}
|
||||
|
||||
// slackPublishHome renders and publishes the Home tab for one user.
|
||||
//
|
||||
// It answers the two questions someone opening Home actually has — what can this
|
||||
// do, and what do I type — and then gets out of the way. A Home that reads like a
|
||||
// landing page teaches nothing.
|
||||
//
|
||||
// A user who has not linked sees the connect prompt INSTEAD of the settings,
|
||||
// because a model preference for an identity we cannot resolve is a control that
|
||||
// does nothing.
|
||||
func slackPublishHome(s *cloud.Service[state], ctx context.Context, botToken, org, user string) error {
|
||||
link, linked, err := getUserLink(s, org, "slack", user)
|
||||
if err != nil {
|
||||
// A Home that cannot read the link still renders — unlinked is the honest
|
||||
// fallback, and the page is the place a person fixes exactly that.
|
||||
s.Log.Warn("slack home: link lookup", "org", org, "err", err)
|
||||
linked = false
|
||||
}
|
||||
return slackChatPost(ctx, botToken, "/views.publish", map[string]any{
|
||||
"user_id": user,
|
||||
"view": homeView(s, org, user, link, linked),
|
||||
})
|
||||
}
|
||||
|
||||
func section(text string) map[string]any {
|
||||
return map[string]any{"type": "section", "text": map[string]any{"type": "mrkdwn", "text": text}}
|
||||
}
|
||||
|
||||
func divider() map[string]any { return map[string]any{"type": "divider"} }
|
||||
|
||||
// homeView is the whole page, as data. Split out from the publish so a test can
|
||||
// assert the page WITHOUT a Slack round trip — the shape of this view is the
|
||||
// contract, and a screenshot is not a test.
|
||||
func homeView(s *cloud.Service[state], org, user string, link userLink, linked bool) map[string]any {
|
||||
blocks := []map[string]any{
|
||||
{"type": "header", "text": map[string]any{"type": "plain_text", "text": "Hanzo AI", "emoji": true}},
|
||||
section("The Open AI Cloud, in Slack. Ask a question, write and ship code, or query your own infrastructure."),
|
||||
divider(),
|
||||
}
|
||||
|
||||
if !linked {
|
||||
// Unlinked: ONE instruction, and it is the one that unblocks everything.
|
||||
// Settings are withheld deliberately — a model chosen by an identity we
|
||||
// cannot resolve is a control that quietly does nothing.
|
||||
blocks = append(blocks,
|
||||
section("*Connect your Hanzo account*\nSend me a direct message and I will hand you a personal connect link. Once connected, every answer runs as _you_, against your org's own agents and balance."),
|
||||
divider(),
|
||||
section("*Then try*\n• `@Hanzo what changed on main today?`\n• `@Hanzo why is my service returning 500s?`\n• `@Hanzo code: my-repo add a health check`"),
|
||||
)
|
||||
return map[string]any{"type": "home", "blocks": blocks}
|
||||
}
|
||||
|
||||
model := strings.TrimSpace(link.Model)
|
||||
if model == "" {
|
||||
model = homeModels[0].Value
|
||||
}
|
||||
routing := strings.TrimSpace(link.Routing)
|
||||
if routing != routingCode {
|
||||
routing = routingChat
|
||||
}
|
||||
|
||||
blocks = append(blocks,
|
||||
// The selected option must be stated as initial_option or Slack renders the
|
||||
// menu blank and the person's own saved choice looks lost.
|
||||
map[string]any{
|
||||
"type": "section",
|
||||
"text": map[string]any{"type": "mrkdwn", "text": "*Model*\nWhich model answers your messages."},
|
||||
"accessory": modelSelect(model),
|
||||
},
|
||||
map[string]any{
|
||||
"type": "section",
|
||||
"text": map[string]any{"type": "mrkdwn", "text": "*Mode*\nWhat a message is allowed to start."},
|
||||
"accessory": routingSelect(routing),
|
||||
},
|
||||
divider(),
|
||||
section("*Connected*\n"+linkedAs(link, org)),
|
||||
divider(),
|
||||
section("*Getting started*\n"+
|
||||
"• Send me a direct message — no mention needed\n"+
|
||||
"• `@Hanzo` in any channel you have invited me to\n"+
|
||||
"• `/hanzo <question>` anywhere, without inviting me\n"+
|
||||
"• `@Hanzo code: <repo> <task>` starts a coding run: I work the repo in a sandbox, push a branch and open a PR, and report back in the thread"),
|
||||
section("_I only post in channels I am a member of — invite me with_ `/invite @Hanzo`_. That is deliberate: I hold no permission to post anywhere uninvited._"),
|
||||
map[string]any{"type": "context", "elements": []map[string]any{
|
||||
{"type": "mrkdwn", "text": "<https://hanzo.ai|hanzo.ai> · <https://docs.hanzo.ai|Docs> · <https://cloud.hanzo.ai|Console>"},
|
||||
}},
|
||||
)
|
||||
return map[string]any{"type": "home", "blocks": blocks}
|
||||
}
|
||||
|
||||
// linkedAs says WHO the turns run as, in the org they bill. It is the line that
|
||||
// answers "is this thing acting as me, and against which account".
|
||||
func linkedAs(link userLink, org string) string {
|
||||
who := strings.TrimSpace(link.Subject)
|
||||
if who == "" {
|
||||
who = "your Hanzo account"
|
||||
}
|
||||
return "Running as `" + who + "` in *" + org + "*."
|
||||
}
|
||||
|
||||
func modelSelect(selected string) map[string]any {
|
||||
opts := make([]map[string]any, 0, len(homeModels))
|
||||
var initial map[string]any
|
||||
for _, m := range homeModels {
|
||||
o := map[string]any{
|
||||
"text": map[string]any{"type": "plain_text", "text": m.Label, "emoji": true},
|
||||
"description": map[string]any{"type": "plain_text", "text": m.Note},
|
||||
"value": m.Value,
|
||||
}
|
||||
opts = append(opts, o)
|
||||
if m.Value == selected {
|
||||
initial = o
|
||||
}
|
||||
}
|
||||
sel := map[string]any{
|
||||
"type": "static_select",
|
||||
"action_id": homeActionModel,
|
||||
"options": opts,
|
||||
}
|
||||
if initial != nil {
|
||||
sel["initial_option"] = initial
|
||||
}
|
||||
return sel
|
||||
}
|
||||
|
||||
func routingSelect(selected string) map[string]any {
|
||||
modes := []struct{ Value, Label, Note string }{
|
||||
{routingChat, "Chat only", "Answer questions; never start a coding run"},
|
||||
{routingCode, "Chat + Code", "Also let `code:` start a sandbox run and open a PR"},
|
||||
}
|
||||
opts := make([]map[string]any, 0, len(modes))
|
||||
var initial map[string]any
|
||||
for _, m := range modes {
|
||||
o := map[string]any{
|
||||
"text": map[string]any{"type": "plain_text", "text": m.Label, "emoji": true},
|
||||
"description": map[string]any{"type": "plain_text", "text": m.Note},
|
||||
"value": m.Value,
|
||||
}
|
||||
opts = append(opts, o)
|
||||
if m.Value == selected {
|
||||
initial = o
|
||||
}
|
||||
}
|
||||
sel := map[string]any{
|
||||
"type": "static_select",
|
||||
"action_id": homeActionRouting,
|
||||
"options": opts,
|
||||
}
|
||||
if initial != nil {
|
||||
sel["initial_option"] = initial
|
||||
}
|
||||
return sel
|
||||
}
|
||||
|
||||
// ── interactivity ────────────────────────────────────────────────────────────
|
||||
|
||||
// slackInteractionBody reports whether a verified request body is an
|
||||
// INTERACTIVITY payload rather than an Events API envelope.
|
||||
//
|
||||
// Slack posts both to the same request URL, and they are different encodings:
|
||||
// events arrive as a JSON body, interactions as form-encoded `payload=<json>`.
|
||||
// Sniffing the encoding is what Slack's own contract gives us — there is no
|
||||
// header that distinguishes them.
|
||||
func slackInteractionBody(raw []byte) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(string(raw)), "payload=")
|
||||
}
|
||||
|
||||
type slackInteraction struct {
|
||||
Type string `json:"type"`
|
||||
Team struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"team"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"user"`
|
||||
Actions []struct {
|
||||
ActionID string `json:"action_id"`
|
||||
SelectedOption struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"selected_option"`
|
||||
} `json:"actions"`
|
||||
}
|
||||
|
||||
// parseSlackInteraction lifts the interaction out of the form body. It reads the
|
||||
// team and user from the PAYLOAD Slack signed, never from anywhere else.
|
||||
func parseSlackInteraction(raw []byte) (slackInteraction, bool) {
|
||||
form, err := url.ParseQuery(string(raw))
|
||||
if err != nil {
|
||||
return slackInteraction{}, false
|
||||
}
|
||||
blob := form.Get("payload")
|
||||
if blob == "" {
|
||||
return slackInteraction{}, false
|
||||
}
|
||||
var in slackInteraction
|
||||
if err := json.Unmarshal([]byte(blob), &in); err != nil {
|
||||
return slackInteraction{}, false
|
||||
}
|
||||
return in, in.Type == "block_actions" && in.User.ID != "" && in.Team.ID != ""
|
||||
}
|
||||
|
||||
// slackHandleInteraction applies a Home setting change and republishes the page.
|
||||
//
|
||||
// ISOLATION is the same bar as every other Slack path: the org comes ONLY from
|
||||
// the install→org map for the Slack-verified team_id, never from the payload.
|
||||
// The payload names WHICH SETTING and WHICH USER — it never names the tenant.
|
||||
//
|
||||
// A value not in our own menu is DROPPED rather than stored. The options came
|
||||
// from us, so anything else is a forged payload or a stale client, and storing
|
||||
// it would let a caller choose the model that bills their org.
|
||||
func slackHandleInteraction(s *cloud.Service[state], c *zip.Ctx, raw []byte) error {
|
||||
in, ok := parseSlackInteraction(raw)
|
||||
if !ok {
|
||||
return c.NoContent(200)
|
||||
}
|
||||
org, ok := OrgForExternalID("slack", in.Team.ID)
|
||||
if !ok {
|
||||
return c.NoContent(200)
|
||||
}
|
||||
link, linked, err := getUserLink(s, org, "slack", in.User.ID)
|
||||
if err != nil || !linked {
|
||||
// Nothing to store a preference on. Republish so the page shows the connect
|
||||
// prompt rather than controls that would silently do nothing.
|
||||
if tok, terr := TokenFor(c.Context(), org, "slack", slackBotTokenSecret); terr == nil {
|
||||
_ = slackPublishHome(s, c.Context(), string(tok), org, in.User.ID)
|
||||
}
|
||||
return c.NoContent(200)
|
||||
}
|
||||
|
||||
changed := false
|
||||
for _, a := range in.Actions {
|
||||
v := strings.TrimSpace(a.SelectedOption.Value)
|
||||
switch a.ActionID {
|
||||
case homeActionModel:
|
||||
if validHomeModel(v) {
|
||||
link.Model, changed = v, true
|
||||
}
|
||||
case homeActionRouting:
|
||||
if v == routingChat || v == routingCode {
|
||||
link.Routing, changed = v, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
if err := putUserLink(s, org, "slack", in.User.ID, link); err != nil {
|
||||
s.Log.Warn("slack home: save preference", "org", org, "err", err)
|
||||
}
|
||||
}
|
||||
// Republish either way: the page a person is looking at must show what is
|
||||
// actually stored, including when a write failed.
|
||||
if tok, terr := TokenFor(c.Context(), org, "slack", slackBotTokenSecret); terr == nil {
|
||||
_ = slackPublishHome(s, c.Context(), string(tok), org, in.User.ID)
|
||||
}
|
||||
return c.NoContent(200)
|
||||
}
|
||||
|
||||
// validHomeModel accepts only a value this deployment actually offered.
|
||||
func validHomeModel(v string) bool {
|
||||
for _, m := range homeModels {
|
||||
if m.Value == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package integrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func viewJSON(t *testing.T, v map[string]any) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("view must marshal: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// A linked user gets the controls, with THEIR OWN choice pre-selected. Without
|
||||
// initial_option Slack renders the menu blank and a saved choice looks lost.
|
||||
func TestHomeShowsSelectedModel(t *testing.T) {
|
||||
v := homeView(nil, "acme", "U1", userLink{Subject: "acme/z", Model: "enso-ultra"}, true)
|
||||
js := viewJSON(t, v)
|
||||
if !strings.Contains(js, `"initial_option"`) {
|
||||
t.Error("the saved choice must be pre-selected, or it looks lost")
|
||||
}
|
||||
if !strings.Contains(js, `"enso-ultra"`) {
|
||||
t.Error("the selected model must appear in the view")
|
||||
}
|
||||
for _, want := range []string{homeActionModel, homeActionRouting} {
|
||||
if !strings.Contains(js, want) {
|
||||
t.Errorf("the view must carry the %q control", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No stored choice must still render a usable menu, defaulted to the auto SKU.
|
||||
func TestHomeDefaultsToEnsoAuto(t *testing.T) {
|
||||
js := viewJSON(t, homeView(nil, "acme", "U1", userLink{Subject: "acme/z"}, true))
|
||||
if !strings.Contains(js, `"enso"`) {
|
||||
t.Error("the default menu must offer enso auto")
|
||||
}
|
||||
}
|
||||
|
||||
// An UNLINKED user is shown how to connect and NOT given settings — a model
|
||||
// chosen by an identity we cannot resolve is a control that does nothing.
|
||||
func TestHomeUnlinkedShowsConnectNotSettings(t *testing.T) {
|
||||
js := viewJSON(t, homeView(nil, "acme", "U1", userLink{}, false))
|
||||
if strings.Contains(js, homeActionModel) {
|
||||
t.Error("an unlinked user must not be offered a model control")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(js), "connect") {
|
||||
t.Error("an unlinked user must be told how to connect")
|
||||
}
|
||||
}
|
||||
|
||||
// Interactivity and events share one signed URL and differ only by encoding.
|
||||
func TestInteractionBodyIsDetected(t *testing.T) {
|
||||
if !slackInteractionBody([]byte(`payload=%7B%22type%22%3A%22block_actions%22%7D`)) {
|
||||
t.Error("a form-encoded payload= body is an interaction")
|
||||
}
|
||||
if slackInteractionBody([]byte(`{"type":"event_callback"}`)) {
|
||||
t.Error("a JSON events envelope is NOT an interaction")
|
||||
}
|
||||
}
|
||||
|
||||
// The payload names the setting and the user; it must never name the tenant.
|
||||
func TestInteractionParsesUserAndAction(t *testing.T) {
|
||||
raw := []byte(`payload=%7B%22type%22%3A%22block_actions%22%2C%22team%22%3A%7B%22id%22%3A%22T1%22%7D%2C%22user%22%3A%7B%22id%22%3A%22U1%22%7D%2C%22actions%22%3A%5B%7B%22action_id%22%3A%22hanzo_model%22%2C%22selected_option%22%3A%7B%22value%22%3A%22enso-flash%22%7D%7D%5D%7D`)
|
||||
in, ok := parseSlackInteraction(raw)
|
||||
if !ok {
|
||||
t.Fatal("a block_actions payload must parse")
|
||||
}
|
||||
if in.User.ID != "U1" || in.Team.ID != "T1" {
|
||||
t.Errorf("user/team must come from the signed payload, got %q/%q", in.User.ID, in.Team.ID)
|
||||
}
|
||||
if len(in.Actions) != 1 || in.Actions[0].SelectedOption.Value != "enso-flash" {
|
||||
t.Errorf("the chosen value must survive parsing, got %+v", in.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
// Only a value WE offered may be stored: the menu came from us, so anything
|
||||
// else is a stale client or a forged payload choosing what this org pays for.
|
||||
func TestOnlyOfferedModelsAreAccepted(t *testing.T) {
|
||||
for _, ok := range []string{"enso", "enso-flash", "enso-ultra"} {
|
||||
if !validHomeModel(ok) {
|
||||
t.Errorf("%q is on the menu and must be accepted", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"gpt-4", "", "best", "enso; drop", "ENSO"} {
|
||||
if validHomeModel(bad) {
|
||||
t.Errorf("%q was never offered and must be refused", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,6 +483,9 @@ func mountRuntime(deps cloud.Deps) error {
|
||||
} else if h != nil {
|
||||
gh := gate(h)
|
||||
runtimeHandler = gh
|
||||
// The embedded runtime is one router that matches the request's own path,
|
||||
// so every declared address reaches the same door: o11y.Whole, which is
|
||||
// what SetHandler meant before a runtime could resolve per address.
|
||||
o11y.SetRuntime(o11y.Whole(gh))
|
||||
// Runtime (and its ONE datastore connection) is live; start native
|
||||
// metrics ingest — opt-in, fail-soft (metrics.go).
|
||||
@@ -504,6 +507,8 @@ func mountRuntime(deps cloud.Deps) error {
|
||||
}
|
||||
gh := gate(h)
|
||||
runtimeHandler = gh
|
||||
// A reverse proxy has one door and the far side selects the route, so there
|
||||
// is nothing here to resolve per address.
|
||||
o11y.SetRuntime(o11y.Whole(gh))
|
||||
log.Info("o11y runtime handler installed (reverse proxy fallback)", "upstream", upstream())
|
||||
return nil
|
||||
|
||||
@@ -90,8 +90,9 @@ func TestTheCacheBackendFollowsWhatIsReachable(t *testing.T) {
|
||||
"use_path_style=true",
|
||||
// Keyed per repository inside the shared bucket.
|
||||
"name=hanzoai-cloud",
|
||||
// The intermediate stages are where the expensive steps live.
|
||||
"mode=max",
|
||||
// min, not max: max re-compressed and re-uploaded the whole build stage
|
||||
// (221.3s on cloud) to cache four steps worth 4.6s.
|
||||
"mode=min",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("s3 cache missing %q: %s", want, got)
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestBuildFrontendCmdCarriesASharedCache(t *testing.T) {
|
||||
got := join(buildFrontendCmd("ctx", "Dockerfile", "ghcr.io/hanzoai/studio:v1.2.3"))
|
||||
for _, want := range []string{
|
||||
"--import-cache type=registry,ref=ghcr.io/hanzoai/studio:buildcache",
|
||||
"--export-cache type=registry,ref=ghcr.io/hanzoai/studio:buildcache,mode=max",
|
||||
"--export-cache type=registry,ref=ghcr.io/hanzoai/studio:buildcache,mode=min,compression=zstd",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("build command missing %q\ngot:%s", want, got)
|
||||
@@ -70,3 +70,33 @@ func TestBuildFrontendCmdCarriesASharedCache(t *testing.T) {
|
||||
t.Errorf("digest ref must carry no cache flags, got:%s", d)
|
||||
}
|
||||
}
|
||||
|
||||
// The layer compression is three settings that only work together, and each one
|
||||
// fails QUIETLY on its own — which is why they are asserted rather than trusted.
|
||||
// Measured on cloud's 1.63GB layer: gzip 245.5s/256.1s against zstd 58.1s/53.3s.
|
||||
//
|
||||
// - compression=zstd alone: buildkit reuses any blob it already has in the
|
||||
// requested-or-not format, so it can publish gzip layers having done no work,
|
||||
// and the only symptom is that the build is as slow as it was before.
|
||||
// - without oci-mediatypes: a zstd layer has no Docker schema2 media type to be
|
||||
// named by, so the manifest cannot describe what was pushed.
|
||||
// - and the cache export must agree with the image, or mode=min re-compresses
|
||||
// the very layers the image export just wrote and hands the saving back.
|
||||
func TestTheImageAndItsCacheAgreeOnCompression(t *testing.T) {
|
||||
got := ""
|
||||
for _, a := range buildFrontendCmd("ctx", "Dockerfile", "ghcr.io/hanzoai/studio:v1.2.3") {
|
||||
got += " " + a.(string)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"compression=zstd",
|
||||
"force-compression=true",
|
||||
"oci-mediatypes=true",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("image output missing %q — the layer export silently falls back to single-core gzip\ngot:%s", want, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "--export-cache") || !strings.Contains(got, "mode=min,compression=zstd") {
|
||||
t.Errorf("the cache export must be min and must match the image's compression\ngot:%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
+67
-12
@@ -945,18 +945,47 @@ func buildFrontendCmdRev(buildCtx, dockerfile, image, revision string) []any {
|
||||
// `buildcache` tag (the standard convention) so it is per-repo, needs no extra
|
||||
// credentials, and is garbage-collected with the package.
|
||||
//
|
||||
// mode=max exports intermediate stages too, which is what makes a one-line code
|
||||
// change reuse the dependency layers instead of rebuilding them. Both flags are
|
||||
// ADVISORY in buildkit: a missing or unreadable cache ref is a cache miss, never
|
||||
// a build failure, so a first build (or a registry hiccup) behaves exactly as it
|
||||
// does today. Skipped for a digest-pinned ref, which names no tag to hang the
|
||||
// cache off.
|
||||
// What the cache can and cannot buy is measured, not assumed — see cacheArgs.
|
||||
// Both flags are ADVISORY in buildkit: a missing or unreadable cache ref is a
|
||||
// cache miss, never a build failure, so a first build (or a registry hiccup)
|
||||
// behaves exactly as it does today. Skipped for a digest-pinned ref, which
|
||||
// names no tag to hang the cache off.
|
||||
if repo, tag := splitImageRef(image); tag != "" && !strings.Contains(tag, ":") {
|
||||
for _, a := range cacheArgs(repo) {
|
||||
cmd = append(cmd, a)
|
||||
}
|
||||
}
|
||||
return append(cmd, "--output", "type=image,name="+image+",push=true")
|
||||
// ZSTD, not buildkit's default gzip. This image is ONE 1.63GB layer — the
|
||||
// per-app plugin binaries, 98.8% of its 1.65GB — and gzip writes a layer as a
|
||||
// single stream on a single core, so the export ran at 23.7MB/s with seven of
|
||||
// the runner's eight CPUs idle: 175.0s and 174.7s on two consecutive builds.
|
||||
// Measured on those same bytes, both orderings, 2026-08-06: gzip 245.5s/256.1s
|
||||
// against zstd 58.1s/53.3s. The zstd result is also 1.2% SMALLER (1,626,561,616
|
||||
// vs 1,646,830,883 bytes), so every pull gets slightly cheaper too.
|
||||
//
|
||||
// oci-mediatypes is the enabling half, not decoration: a zstd layer is
|
||||
// application/vnd.oci.image.layer.v1.tar+zstd, and the Docker schema2 manifest
|
||||
// this lane emitted before has no media type that can name one. Setting the
|
||||
// compression without it would push layers no client could read.
|
||||
//
|
||||
// force-compression is load-bearing, not belt-and-braces. Without it buildkit
|
||||
// treats any already-compressed blob as good enough and reuses it: asked for
|
||||
// zstd over layers it had itself just written as gzip, it published an OCI
|
||||
// manifest whose layers were still ...layer.v1.tar+gzip, having done no
|
||||
// compression work at all. That failure is silent and lands in the "still
|
||||
// works, only slow" direction — the kind that survives review and quietly gives
|
||||
// back the whole saving. Forcing it means what ships is what was measured. The
|
||||
// price is re-compressing ~11MB of alpine base layers, under a second.
|
||||
//
|
||||
// Verified before shipping rather than assumed, because the blast radius is
|
||||
// "nothing in the fleet can pull our images": ghcr.io stores the zstd blobs,
|
||||
// and kubelet/containerd 1.7.28 pulled and RAN the resulting image on two
|
||||
// different node pools, including one that had never seen the bytes.
|
||||
//
|
||||
// One caller had to change WITH this and is not optional: imagePullable in
|
||||
// pin.go negotiates the manifest by Accept, and ghcr answers a type it was not
|
||||
// offered with 404. See the note there.
|
||||
return append(cmd, "--output", "type=image,name="+image+",push=true,compression=zstd,force-compression=true,oci-mediatypes=true")
|
||||
}
|
||||
|
||||
// cacheBucket is the object-store bucket the layer cache lives in. One bucket for
|
||||
@@ -987,15 +1016,41 @@ func cacheArgs(repo string) []string {
|
||||
",region=" + getenv("S3_REGION", "us-east-1") +
|
||||
",endpoint_url=" + s3CacheEndpoint(ep) +
|
||||
",use_path_style=true,name=" + cacheKey(repo)
|
||||
// mode=max exports the intermediate stages too, which is where the Go
|
||||
// compiles live; without it a multi-stage build caches only its final
|
||||
// layers and the expensive steps rerun anyway.
|
||||
return []string{"--import-cache", common, "--export-cache", common + ",mode=max"}
|
||||
// mode=min, for the reason spelled out on the registry branch below: max
|
||||
// re-compresses and re-uploads the whole build stage to buy back a few
|
||||
// seconds of `apk add`.
|
||||
return []string{"--import-cache", common, "--export-cache", common + ",mode=min,compression=zstd"}
|
||||
}
|
||||
ref := repo + ":buildcache"
|
||||
// mode=min. The claim this used to carry — that max keeps the Go compiles warm
|
||||
// — is not what the builds do. Two independent cloud builds imported this cache
|
||||
// and got the SAME eight cached steps: `apk add`, `adduser`, the libsqlcipher
|
||||
// symlink and WORKDIR, once per stage. Every expensive step missed both times
|
||||
// and had to: `COPY . .` sits in front of them and its digest changes with
|
||||
// every commit, and the compiles write into `--mount=type=cache`, which is
|
||||
// worker-local and never travels in an exported cache at all — a build pod is
|
||||
// fresh, so it starts them cold no matter what this flag says.
|
||||
//
|
||||
// What max charged for those eight steps: it exports every intermediate stage,
|
||||
// and the build stage holds the same 4.2GB of plugin binaries the image does,
|
||||
// so buildkit compressed them a SECOND time and pushed a second 1.6GB blob.
|
||||
// Measured 2026-08-06 — 221.3s and 225.2s of a 17-minute build, reproduced at
|
||||
// 212.1s on a synthetic build of the same shape, against 1.8s for min.
|
||||
//
|
||||
// Four of the eight steps are stage-2, i.e. layers of the FINAL image, so min
|
||||
// still exports them. Only the four build-stage records are given up, and those
|
||||
// are worth 4.6s cold (measured: 4.4 + 0.1 + 0.1 + 0.0).
|
||||
//
|
||||
// compression matches the image's, which is what keeps min cheap: min exports
|
||||
// the final image's layers, so if the two settings disagree buildkit has to
|
||||
// re-compress all of them here and the saving is handed straight back.
|
||||
//
|
||||
// Both flags stay ADVISORY in buildkit — a missing or unreadable cache ref is a
|
||||
// cache miss, never a build failure — so --import-cache keeps working unchanged
|
||||
// against a ref written either way, including one written by the old mode.
|
||||
return []string{
|
||||
"--import-cache", "type=registry,ref=" + ref,
|
||||
"--export-cache", "type=registry,ref=" + ref + ",mode=max",
|
||||
"--export-cache", "type=registry,ref=" + ref + ",mode=min,compression=zstd",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -303,9 +303,24 @@ func imagePullable(ctx context.Context, repository, tag string) error {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
// Every media type an image of ours can be, because ghcr honours Accept
|
||||
// STRICTLY: ask for a manifest whose type you did not offer and it answers 404
|
||||
// — the identical status a tag that was never pushed returns. A probe that
|
||||
// cannot tell those apart reports a present image as a phantom tag and refuses
|
||||
// to pin it, which is the failure this function exists to prevent.
|
||||
//
|
||||
// The OCI image manifest is the one that used to be missing, and its absence
|
||||
// was invisible only because every image here was gzip: buildkit writes Docker
|
||||
// schema2 for a gzip layer and application/vnd.oci.image.manifest.v1+json for a
|
||||
// zstd one. So this list was coupled to the BUILDER'S COMPRESSION SETTING
|
||||
// without saying so, and the day that setting changed, every release pin would
|
||||
// have failed while truthfully insisting the registry did not have the image.
|
||||
// Measured against ghcr 2026-08-06 with a real zstd image: without the OCI type
|
||||
// 404, with it 200, and a nonexistent tag 404 either way.
|
||||
req.Header.Set("Accept", strings.Join([]string{
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
}, ","))
|
||||
resp, err := releaseHTTP.Do(req)
|
||||
|
||||
+87
-9
@@ -197,14 +197,15 @@ func (a *httpAI) ChatCompletion(ctx context.Context, req *types.ChatRequest) (*t
|
||||
defer cancel()
|
||||
|
||||
resp, err := a.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
|
||||
Model: model,
|
||||
Messages: []openai.ChatCompletionMessage{
|
||||
{Role: openai.ChatMessageRoleUser, Content: req.Prompt},
|
||||
},
|
||||
Model: model,
|
||||
Messages: wireMessages(req),
|
||||
// The ceiling the prepaid gate RESERVED. Sending it is what makes the
|
||||
// reservation binding: without it the provider picks its own limit and
|
||||
// can return a completion nobody paid for.
|
||||
MaxTokens: req.MaxTokens,
|
||||
// Absent unless the caller offered tools, so a request that offers none
|
||||
// is the same bytes on the wire it always was.
|
||||
Tools: wireTools(req.Tools),
|
||||
})
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
@@ -228,14 +229,93 @@ func (a *httpAI) ChatCompletion(ctx context.Context, req *types.ChatRequest) (*t
|
||||
span.SetStatus(codes.Error, "no choices")
|
||||
return nil, fmt.Errorf("cloud: chat completion (model %q): upstream returned no choices: %w", model, types.ErrUpstreamBusy)
|
||||
}
|
||||
choice := resp.Choices[0]
|
||||
return &types.ChatResponse{
|
||||
Content: resp.Choices[0].Message.Content,
|
||||
Content: choice.Message.Content,
|
||||
PromptTokens: resp.Usage.PromptTokens,
|
||||
CompletionTokens: resp.Usage.CompletionTokens,
|
||||
TotalTokens: resp.Usage.TotalTokens,
|
||||
ToolCalls: readToolCalls(choice.Message.ToolCalls),
|
||||
FinishReason: string(choice.FinishReason),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// wireMessages renders a request's conversation for the gateway. A request that
|
||||
// carries no Messages is the single user turn this client has always sent —
|
||||
// identical bytes, so the no-tools path is untouched.
|
||||
//
|
||||
// An assistant turn that called tools is sent back with its ToolCalls and a
|
||||
// RoleTool turn with its ToolCallID, because that pair is what the gateway
|
||||
// matches a result to its call by; drop either and the model is answered with an
|
||||
// orphan.
|
||||
func wireMessages(req *types.ChatRequest) []openai.ChatCompletionMessage {
|
||||
if len(req.Messages) == 0 {
|
||||
return []openai.ChatCompletionMessage{{Role: openai.ChatMessageRoleUser, Content: req.Prompt}}
|
||||
}
|
||||
out := make([]openai.ChatCompletionMessage, 0, len(req.Messages))
|
||||
for _, m := range req.Messages {
|
||||
msg := openai.ChatCompletionMessage{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
Name: m.Name,
|
||||
ToolCallID: m.ToolCallID,
|
||||
}
|
||||
for _, tc := range m.ToolCalls {
|
||||
msg.ToolCalls = append(msg.ToolCalls, openai.ToolCall{
|
||||
ID: tc.ID,
|
||||
Type: openai.ToolTypeFunction,
|
||||
Function: openai.FunctionCall{Name: tc.Name, Arguments: tc.Arguments},
|
||||
})
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wireTools renders the offered tools as OpenAI function definitions. The schema
|
||||
// crosses as json.RawMessage — the tool plane's own inputSchema, verbatim — so
|
||||
// nothing here has an opinion about what a tool's arguments look like. A tool
|
||||
// that declares no schema is offered as taking an empty object rather than as
|
||||
// taking nothing, which is what a model needs to emit valid arguments for it.
|
||||
func wireTools(defs []types.ToolDef) []openai.Tool {
|
||||
if len(defs) == 0 {
|
||||
return nil // absent field, not an empty array: an empty one is a refusal to use tools
|
||||
}
|
||||
out := make([]openai.Tool, 0, len(defs))
|
||||
for _, d := range defs {
|
||||
schema := d.Schema
|
||||
if len(schema) == 0 {
|
||||
schema = json.RawMessage(`{"type":"object"}`)
|
||||
}
|
||||
out = append(out, openai.Tool{
|
||||
Type: openai.ToolTypeFunction,
|
||||
Function: &openai.FunctionDefinition{
|
||||
Name: d.Name,
|
||||
Description: d.Description,
|
||||
Parameters: schema,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readToolCalls lifts the model's tool calls off a choice. Only function calls
|
||||
// are carried: they are the only kind this gateway serves, and a call of some
|
||||
// other type has no arguments this side could dispatch.
|
||||
func readToolCalls(calls []openai.ToolCall) []types.ToolCall {
|
||||
if len(calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]types.ToolCall, 0, len(calls))
|
||||
for _, c := range calls {
|
||||
if c.Type != "" && c.Type != openai.ToolTypeFunction {
|
||||
continue
|
||||
}
|
||||
out = append(out, types.ToolCall{ID: c.ID, Name: c.Function.Name, Arguments: c.Function.Arguments})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ChatStream is the types.StreamCompleter capability: the SAME completion as
|
||||
// ChatCompletion, delivered delta by delta as the model produces it. It is one
|
||||
// upstream call with stream:true (+ stream_options.include_usage so the terminal
|
||||
@@ -260,10 +340,8 @@ func (a *httpAI) ChatStream(ctx context.Context, req *types.ChatRequest, emit fu
|
||||
defer cancel()
|
||||
|
||||
stream, err := a.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
|
||||
Model: model,
|
||||
Messages: []openai.ChatCompletionMessage{
|
||||
{Role: openai.ChatMessageRoleUser, Content: req.Prompt},
|
||||
},
|
||||
Model: model,
|
||||
Messages: wireMessages(req),
|
||||
// Same ceiling the gate reserved — streaming must not be a way to buy
|
||||
// more completion than was paid for.
|
||||
MaxTokens: req.MaxTokens,
|
||||
|
||||
+8
-2
@@ -252,7 +252,13 @@ func run(addr, zapAddr string) error {
|
||||
// middleware on a sibling's router (zen on ai's), so it is not a child this
|
||||
// host can start and its ops are already in the sibling's registry — asking
|
||||
// for it by name would report a permanent outage for an app that is serving.
|
||||
fleet.Mount(app, manifest.MCPPath, routed(composed), locate(app))
|
||||
//
|
||||
// The Door is KEPT, because the fleet's own subsystems need it as much as an
|
||||
// external client does — an agent run inside `agents` has to resolve its tool
|
||||
// names against the same aggregated surface. serveWake publishes this same
|
||||
// object on the host's internal socket, so there is one gather, one routing
|
||||
// table and one curation rule for both directions.
|
||||
mcp := fleet.Mount(app, manifest.MCPPath, routed(composed), locate(app))
|
||||
|
||||
// The bare /mcp needs no route here. webui's terminal handler answers it from
|
||||
// manifest.MCPPath (webui/mcp.go) — one rule, in the one place that can tell a
|
||||
@@ -331,7 +337,7 @@ func run(addr, zapAddr string) error {
|
||||
// is there. Without it every internal call to a lazy app dials a socket that
|
||||
// no request has ever caused to exist (wake.go). It closes with the app, so
|
||||
// there is nothing here to defer and nothing to forget to.
|
||||
serveWake(app)
|
||||
serveWake(app, mcp)
|
||||
|
||||
// SIGTERM must reach the children. zip drains its shutdown hooks LIFO, and
|
||||
// every Load registered one that stops its process, so this is what keeps a
|
||||
|
||||
+26
-1
@@ -5,6 +5,8 @@ import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/fleet"
|
||||
"github.com/hanzoai/cloud/manifest"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
@@ -31,6 +33,22 @@ import (
|
||||
// reaching for cloud.Plane() — which would pull the entire fleet's package graph
|
||||
// into a router that deliberately links none of it. One op, one socket, one
|
||||
// import of the leaf both halves already share.
|
||||
//
|
||||
// # The AGENT DOOR is on this socket too, and for the same reason
|
||||
//
|
||||
// A subsystem cannot enumerate its siblings — a plugin is a process, and
|
||||
// MCPTools() is in-process — so an agent run inside `agents` could resolve its
|
||||
// declared tool names against nothing but its own registry, which in the split
|
||||
// fleet holds what `agents` itself registered and no more. The thing that DOES
|
||||
// aggregate already exists and is already mounted: fleet.Door, at
|
||||
// api.hanzo.ai/v1/mcp. The only part it was missing was an address reachable
|
||||
// from inside.
|
||||
//
|
||||
// It is the SAME Door object, not a second one. So an agent gets the same union
|
||||
// its external MCP clients get, ordered by the same rank and — the part that
|
||||
// matters — narrowed by the same [fleet] curation rule, which is applied inside
|
||||
// gather where the routing table is written. A tool the door will not project to
|
||||
// Slack is not routable for an agent either, and both hear the same -32602.
|
||||
|
||||
// serveWake opens the router's start door at plane.HostApp's socket.
|
||||
//
|
||||
@@ -46,9 +64,16 @@ import (
|
||||
// logged where the socket is named, so the degradation is visible rather than
|
||||
// inferred — and it does not return until the socket ACCEPTS, so "listening" in the
|
||||
// log is a fact rather than an intention.
|
||||
func serveWake(app *zip.App) {
|
||||
func serveWake(app *zip.App, mcp *fleet.Door) {
|
||||
door := zip.New(zip.Config{AppName: "plane", Logger: app.Logger()})
|
||||
|
||||
// The fleet's agent door, at its OWN address (manifest.MCPPath) on this
|
||||
// socket. One name for one door across both transports: over HTTP it is the
|
||||
// edge's /v1/mcp, over ZAP it is the fleet's own. Nothing here re-aggregates
|
||||
// and nothing here filters — [fleet.Door.Serve] publishes the object main.go
|
||||
// already built.
|
||||
mcp.Serve(door, manifest.MCPPath)
|
||||
|
||||
zip.Post[plane.StartIn, plane.Started](door, "/host/start",
|
||||
func(_ context.Context, in *plane.StartIn) (*plane.Started, error) {
|
||||
// No tenancy check, because there is no tenant: starting a process
|
||||
|
||||
@@ -27,6 +27,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/fleet"
|
||||
"github.com/hanzoai/cloud/manifest"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
@@ -88,7 +90,9 @@ func router(t *testing.T, name string) {
|
||||
// Called exactly as run() calls it: no handle, torn down by the app's own
|
||||
// shutdown hooks. If this ever grows a return value again, the caller in
|
||||
// main.go is one `defer f()()` away from never opening the door at all.
|
||||
serveWake(app)
|
||||
// The agent door rides the same socket, over this host's own children —
|
||||
// which is one lazy plugin here, and none of it is what this file tests.
|
||||
serveWake(app, fleet.Mount(app, manifest.MCPPath, routed([]string{name}), locate(app)))
|
||||
waitFor(t, zip.SocketPath(plane.HostApp))
|
||||
}
|
||||
|
||||
|
||||
+38
-10
@@ -77,30 +77,58 @@ noschema = [t["name"] for t in tools if t.get("inputSchema") is None]
|
||||
if noschema:
|
||||
print("FAIL: tools listed with NO inputSchema at all:", ", ".join(noschema[:10])); sys.exit(1)
|
||||
print("every listed tool carries prose and a schema")
|
||||
|
||||
# ONE TOOL PER SUBSYSTEM, the operation in an argument (fleet/grouped.go). The
|
||||
# flat surface was 1,189 tools in 977 KB and clients keep 128, so the count is a
|
||||
# correctness property here and not a nicety.
|
||||
stray = [t["name"] for t in tools if not t["name"].startswith("hanzo_")]
|
||||
if stray:
|
||||
print("FAIL: the door published a flat operation:", ", ".join(stray[:10])); sys.exit(1)
|
||||
ops = [op for t in tools for op in t["inputSchema"].get("properties", {}).get("op", {}).get("enum", [])]
|
||||
print("%d tools carrying %d operations (a client keeps 128)" % (len(tools), len(ops)))
|
||||
if len(tools) >= 128:
|
||||
print("FAIL: %d tools is back over the cap" % len(tools)); sys.exit(1)
|
||||
if not ops:
|
||||
print("FAIL: the door published no operations at all"); sys.exit(1)
|
||||
PY
|
||||
|
||||
echo
|
||||
echo "== one tool, with the doc comment its handler carries =="
|
||||
echo "== one operation, with the doc comment its handler carries =="
|
||||
# A READ by default: the point is to show the owner answering, not to mutate.
|
||||
# hanzo_describe is how a schema is fetched now — the enums carry names only.
|
||||
TOOL="${TOOL:-$(python3 -c '
|
||||
import json,sys
|
||||
tools=json.load(open(sys.argv[1]))["result"]["tools"]
|
||||
reads=[t["name"] for t in tools if t["name"].startswith("get_")]
|
||||
print((reads or [t["name"] for t in tools])[0])' "$CLOUD_DATA_DIR/list.json")}"
|
||||
python3 - "$CLOUD_DATA_DIR/list.json" "$TOOL" <<'PY'
|
||||
import json, sys
|
||||
ops=[op for t in tools for op in t["inputSchema"].get("properties",{}).get("op",{}).get("enum",[])]
|
||||
reads=[o for o in ops if o.startswith("get_")]
|
||||
print((reads or ops)[0])' "$CLOUD_DATA_DIR/list.json")}"
|
||||
GROUP="$(python3 -c '
|
||||
import json,sys
|
||||
for t in json.load(open(sys.argv[1]))["result"]["tools"]:
|
||||
if t["name"] == sys.argv[2]:
|
||||
print(json.dumps(t, indent=2)[:1600]); break
|
||||
if sys.argv[2] in t["inputSchema"].get("properties",{}).get("op",{}).get("enum",[]):
|
||||
print(t["name"]); break
|
||||
else:
|
||||
print("FAIL: %s is not on the door" % sys.argv[2]); sys.exit(1)
|
||||
sys.exit("FAIL: %s is in no subsystem enum" % sys.argv[2])' "$CLOUD_DATA_DIR/list.json" "$TOOL")"
|
||||
echo "$TOOL is served through $GROUP"
|
||||
rpc "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"hanzo_describe\",\"arguments\":{\"op\":\"$TOOL\"}}}" \
|
||||
> "$CLOUD_DATA_DIR/describe.json"
|
||||
python3 - "$CLOUD_DATA_DIR/describe.json" "$TOOL" <<'PY'
|
||||
import json, sys
|
||||
res = json.load(open(sys.argv[1])).get("result") or {}
|
||||
text = "".join(c.get("text", "") for c in res.get("content", []))
|
||||
if not text:
|
||||
print("FAIL: hanzo_describe returned nothing for %s: %s" % (sys.argv[2], json.dumps(res)[:400])); sys.exit(1)
|
||||
d = json.loads(text)
|
||||
if d.get("name") != sys.argv[2] or d.get("inputSchema") is None:
|
||||
print("FAIL: hanzo_describe answered %s" % text[:400]); sys.exit(1)
|
||||
print(json.dumps(d, indent=2)[:1600])
|
||||
PY
|
||||
|
||||
echo
|
||||
echo "== tools/call =="
|
||||
echo "== tools/call, through the subsystem tool =="
|
||||
BEFORE=$(children)
|
||||
ARGS="${ARGS:-{\}}"
|
||||
rpc "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"$TOOL\",\"arguments\":$ARGS}}" | tee "$CLOUD_DATA_DIR/call.json"; echo
|
||||
rpc "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"$GROUP\",\"arguments\":{\"op\":\"$TOOL\",\"input\":$ARGS}}}" | tee "$CLOUD_DATA_DIR/call.json"; echo
|
||||
AFTER=$(children)
|
||||
echo "children before=$BEFORE after=$AFTER (a cold owner wakes exactly one)"
|
||||
[ $((AFTER-BEFORE)) -le 1 ] || { echo "FAIL: tools/call woke $((AFTER-BEFORE)) children; it must wake at most ONE"; exit 1; }
|
||||
|
||||
@@ -38,12 +38,16 @@ done
|
||||
echo "== 3. telemetry is arriving, not just accepted =="
|
||||
# The load-bearing check. Stage 1 can pass while nothing is stored: the door
|
||||
# 200s, the collector is dead, and the write never happens.
|
||||
for probe in "traces:o11y_traces.o11y_index_v3:timestamp" "logs:o11y_logs.logs_v2:toDateTime(max(timestamp)/1e9)"; do
|
||||
name="${probe%%:*}"; rest="${probe#*:}"; tbl="${rest%%:*}"; expr="${rest#*:}"
|
||||
[ "$name" = "traces" ] && expr="max(timestamp)"
|
||||
# Both signals live in the one event plane and date themselves with the same
|
||||
# `time` column, so there is no per-probe expression left to carry. They used to
|
||||
# be o11y_traces.o11y_index_v3 and o11y_logs.logs_v2; those databases are gone,
|
||||
# which made this stage report the chain BROKEN unconditionally -- a check that
|
||||
# always fails tells you nothing, and hides the outage it exists to catch.
|
||||
for probe in "traces:event.span" "logs:event.log"; do
|
||||
name="${probe%%:*}"; tbl="${probe#*:}"
|
||||
mins=$(kubectl exec -n "$NS" "$POD" -- bash -lc \
|
||||
"hanzo-datastore client --host 127.0.0.1 --port 9000 --user \"\$DATASTORE_USER\" --password \"\$DATASTORE_PASSWORD\" \
|
||||
-q \"SELECT dateDiff('minute', $expr, now()) FROM $tbl\"" 2>/dev/null | tr -d '[:space:]')
|
||||
-q \"SELECT dateDiff('minute', max(time), now()) FROM $tbl\"" 2>/dev/null | tr -d '[:space:]')
|
||||
if [ -n "$mins" ] && [ "$mins" -lt 30 ] 2>/dev/null; then
|
||||
ok "$name last written ${mins}m ago"
|
||||
else
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package fleet_test
|
||||
|
||||
// A subsystem with NO typed op is still ASKED, so it must still ANSWER.
|
||||
//
|
||||
// The rest of this package's tests build children out of typed ops, which is the
|
||||
// half of the fleet that was never broken. Thirty of the fleet's 117 subsystems
|
||||
// have no typed op at all — their routes are raw handlers, or they belong to
|
||||
// another module — and for those zip returned before registering a door
|
||||
// (zip@v1.25.1 mcp.go:99: no registry, no plugin catalogue, no per-caller Source
|
||||
// ⇒ no route). Nothing claimed POST /mcp in those processes, the ask fell through
|
||||
// to the console's terminal handler, and it answered the signpost that is right
|
||||
// only on the front door: 308 → /v1/mcp, which inside a child is a 404. The door
|
||||
// read the non-2xx as an outage (fleet/fleet.go, ask) and reported thirty healthy
|
||||
// subsystems as unreachable:
|
||||
//
|
||||
// "exec answered 308 for /mcp"
|
||||
//
|
||||
// So this test composes a REAL one of those thirty the way cloud.Serve composes a
|
||||
// plugin child — cloud.App, its own Mount, the console mounted LAST as the
|
||||
// terminal handler — puts it on a unix socket, and drives the composed door over
|
||||
// that wire. apps/exec is the representative: 56 operations, every one of them a
|
||||
// raw reverse-proxy route by design (apps/exec/typed_wire_test.go is that
|
||||
// ledger), so its registry is empty for the same reason the other twenty-nine are.
|
||||
//
|
||||
// It asserts the ANSWER, never a status: the child's own reply must be a JSON-RPC
|
||||
// result carrying a tools array, and the fleet's door must name no outage for it.
|
||||
// An empty array is a real answer — "asked, serves nothing" is a different fact
|
||||
// from "could not be asked", and telling those apart is what package fleet is for.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/exec"
|
||||
"github.com/hanzoai/cloud/fleet"
|
||||
"github.com/hanzoai/cloud/manifest"
|
||||
"github.com/hanzoai/cloud/webui"
|
||||
)
|
||||
|
||||
// darkChild brings up apps/exec as its own plugin process does, on its own
|
||||
// socket. Nothing here is a fixture: cloud.App is the one constructor every
|
||||
// plugin main reaches Serve through, MountAll is the loop Serve runs, and
|
||||
// webui.Mount is the terminal handler Serve installs last in EVERY process —
|
||||
// which is the handler that answered the door.
|
||||
func darkChild(t *testing.T, name string, mount cloud.MountFunc) *child {
|
||||
t.Helper()
|
||||
cfg := &cloud.Config{Brand: "hanzo", Domain: "api.hanzo.ai", DataDir: t.TempDir(), Enable: []string{name}}
|
||||
deps := cloud.BuildDeps(cfg)
|
||||
|
||||
app := cloud.App(name, cfg, deps, nil)
|
||||
if err := cloud.MountAll(app, []cloud.Plugin{{Name: name, Price: cloud.Free, Mount: mount}}, cfg, deps); err != nil {
|
||||
t.Fatalf("mount %s: %v", name, err)
|
||||
}
|
||||
// LAST, and with no console bundle — a child cannot bootstrap one (serve.go),
|
||||
// so this is the shape production runs.
|
||||
if err := webui.Mount(app, nil); err != nil {
|
||||
t.Fatalf("console %s: %v", name, err)
|
||||
}
|
||||
|
||||
sock := filepath.Join(t.TempDir(), name+".sock")
|
||||
go func() { _ = app.Listen(sock) }()
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
waitFor(t, sock)
|
||||
return &child{name: name, addr: sock, app: app}
|
||||
}
|
||||
|
||||
// TestASubsystemWithNoTypedOpStillAnswersTheDoor is the regression, from both
|
||||
// ends of the hop.
|
||||
func TestASubsystemWithNoTypedOpStillAnswersTheDoor(t *testing.T) {
|
||||
kid := darkChild(t, "exec", exec.Mount)
|
||||
|
||||
// END ONE — the child's own door, at the address the fleet asks. The claim is
|
||||
// about the BYTES: a JSON-RPC result with a tools array. A 308 has neither, and
|
||||
// so did every one of the thirty.
|
||||
req, err := http.NewRequest(http.MethodPost, manifest.FrameworkMCPPath, strings.NewReader(toolsListBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := kid.app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", manifest.FrameworkMCPPath, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if loc := resp.Header.Get("Location"); loc != "" {
|
||||
t.Errorf("the child SIGNPOSTED its own door to %q — that address is a 404 in this process, "+
|
||||
"which is why following the hop is not the fix", loc)
|
||||
}
|
||||
var env struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Result *struct {
|
||||
Tools []json.RawMessage `json:"tools"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil || env.JSONRPC != "2.0" || env.Result == nil {
|
||||
t.Fatalf("POST %s did not answer MCP: status %d — %.160s",
|
||||
manifest.FrameworkMCPPath, resp.StatusCode, body)
|
||||
}
|
||||
if env.Result.Tools == nil {
|
||||
t.Errorf("tools/list answered without a tools array — %.160s", body)
|
||||
}
|
||||
t.Logf("exec POST %s -> %d, %d tools", manifest.FrameworkMCPPath, resp.StatusCode, len(env.Result.Tools))
|
||||
|
||||
// END TWO — the composed door over the same child, over its real socket. A
|
||||
// subsystem that answers must not be NAMED as an outage: that list is what a
|
||||
// client reads to know its catalogue is short, so a false entry there is the
|
||||
// same lie as a silently short list.
|
||||
h := host(t, []string{"exec"}, map[string]*child{"exec": kid})
|
||||
res := rpc(t, h, toolsListBody)
|
||||
for _, o := range outages(t, res) {
|
||||
if o.App == "exec" {
|
||||
t.Fatalf("the door reports exec unreachable: %q — it is up and it answered", o.Error)
|
||||
}
|
||||
}
|
||||
if _, ok := res["tools"]; !ok {
|
||||
t.Fatalf("the door answered without a tools array — %v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheDoorStillNamesASubsystemThatIsGenuinelyDown is the other half, and the
|
||||
// one a fix could quietly destroy: making every child answer must not be done by
|
||||
// making every child LOOK like it answered.
|
||||
func TestTheDoorStillNamesASubsystemThatIsGenuinelyDown(t *testing.T) {
|
||||
kid := darkChild(t, "exec", exec.Mount)
|
||||
die(t, kid)
|
||||
|
||||
h := host(t, []string{"exec"}, map[string]*child{"exec": kid})
|
||||
res := rpc(t, h, toolsListBody)
|
||||
found := false
|
||||
for _, o := range outages(t, res) {
|
||||
if o.App == "exec" {
|
||||
found = true
|
||||
t.Logf("reported, as it must be: %s — %s", o.App, o.Error)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("a stopped subsystem is missing from %s — a short list that does not say so is "+
|
||||
"the defect this package exists to remove", fleet.Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
// outages reads the door's own outage list off the result's _meta.
|
||||
func outages(t *testing.T, res map[string]any) []fleet.Outage {
|
||||
t.Helper()
|
||||
meta, _ := res["_meta"].(map[string]any)
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(meta[fleet.Unavailable])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []fleet.Outage
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("%s is not a list of outages: %v — %s", fleet.Unavailable, err, raw)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const toolsListBody = `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
|
||||
@@ -0,0 +1,280 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package fleet_test
|
||||
|
||||
// The gate, over the wire, with a child that really does serve the dangerous op.
|
||||
//
|
||||
// These tests are worth more than the unit tests beside them in exactly one way:
|
||||
// the child here SERVES CreateServiceAccountKey. Its registry has the op, its
|
||||
// own /mcp lists it, and a direct call to the child would run it. So what is
|
||||
// being tested is the door's REFUSAL, not the absence of the op — which is the
|
||||
// difference between a policy and a coincidence, and the difference the live
|
||||
// server did not have when it projected 1,323 tools with no auth at all.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud/fleet"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// startNamed brings up a child serving exactly the ops named, one route each.
|
||||
// The names are real fleet operation ids, so the child is a faithful stand-in
|
||||
// for the subsystem that serves them.
|
||||
func startNamed(t *testing.T, name string, ops ...string) *child {
|
||||
t.Helper()
|
||||
sock := filepath.Join(t.TempDir(), name+".sock")
|
||||
app := zip.New(zip.Config{AppName: name, DisableStartupMessage: true})
|
||||
for i, id := range ops {
|
||||
route := "/v1/" + name + "/op" + string(rune('a'+i))
|
||||
zip.Post(app, route, func(_ context.Context, in *thingIn) (*thingOut, error) {
|
||||
return &thingOut{App: name, Which: in.Which}, nil
|
||||
}, zip.WithOperationID(id), zip.WithSummary("what "+name+" does at "+id))
|
||||
}
|
||||
go func() { _ = app.Listen(sock) }()
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
waitFor(t, sock)
|
||||
return &child{name: name, addr: sock, app: app}
|
||||
}
|
||||
|
||||
// order is the operations the door offers, IN THE ORDER IT OFFERS THEM — unlike
|
||||
// listed(), which sorts. The order is the mechanism under test in
|
||||
// TestTheProductSurfaceLeadsTheList, so sorting it away would test nothing.
|
||||
func order(t *testing.T, h *zip.App) []string {
|
||||
t.Helper()
|
||||
return offered(rpc(t, h, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
}
|
||||
|
||||
// dangerous and useful are the two halves, as one child each would serve them.
|
||||
var dangerous = []string{
|
||||
"CreateServiceAccountKey", "CreateSessionByEmailPassword", "CreateResetPasswordToken",
|
||||
"GetResetPasswordToken", "CreateUser", "DeleteUser", "CreateAuthDomain",
|
||||
"CreateRole", "SetRoleByUserID", "CreateInvite", "getToken", "get_v1_kms_secrets",
|
||||
}
|
||||
|
||||
var useful = []string{
|
||||
"post_v1_chat_completions", "get_v1_models", "post_v1_embeddings",
|
||||
"post_v1_agents_sessions_by_id_message", "post_v1_code_ask", "post_v1_git_repos",
|
||||
"post_v1_deploy_applications_by_name_sync", "GetUser", "GetUserPreference",
|
||||
}
|
||||
|
||||
// TestTheDoorDoesNotProjectACredentialOpItsChildServes.
|
||||
func TestTheDoorDoesNotProjectACredentialOpItsChildServes(t *testing.T) {
|
||||
kid := startNamed(t, "console", append(append([]string{}, dangerous...), useful...)...)
|
||||
h := host(t, []string{"console"}, map[string]*child{"console": kid})
|
||||
|
||||
// The child really does serve all of them — otherwise this proves nothing.
|
||||
served := map[string]bool{}
|
||||
for _, tl := range kid.app.MCPTools() {
|
||||
served[tl["name"].(string)] = true
|
||||
}
|
||||
for _, n := range dangerous {
|
||||
if !served[n] {
|
||||
t.Fatalf("fixture is wrong: the child does not serve %q, so refusing it at the door proves nothing", n)
|
||||
}
|
||||
}
|
||||
|
||||
got := map[string]bool{}
|
||||
for _, n := range order(t, h) {
|
||||
got[n] = true
|
||||
}
|
||||
for _, n := range dangerous {
|
||||
if got[n] {
|
||||
t.Errorf("the door PROJECTED %q — an agent can mint or read a credential with it", n)
|
||||
}
|
||||
}
|
||||
for _, n := range useful {
|
||||
if !got[n] {
|
||||
t.Errorf("the door dropped %q — the gate ate a product tool", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestARefusedToolIsNotCALLABLE is the half that makes this a boundary.
|
||||
//
|
||||
// A filter applied only to tools/list would leave every already-cached name
|
||||
// callable — and Slack has 128 of them cached right now. The refusal lives in
|
||||
// gather(), which is where the tool→app routing table is written, so a refused
|
||||
// name is never written and a call naming it cannot be dispatched. This test
|
||||
// calls a tool that DOES exist in the child, with no tools/list first, so the
|
||||
// door must refuse it on the discovery path rather than from a stale table.
|
||||
func TestARefusedToolIsNotCALLABLE(t *testing.T) {
|
||||
kid := startNamed(t, "console", "CreateServiceAccountKey", "post_v1_chat_completions")
|
||||
h := host(t, []string{"console"}, map[string]*child{"console": kid})
|
||||
|
||||
res := rpc(t, h, `{"jsonrpc":"2.0","id":3,"method":"tools/call",`+
|
||||
`"params":{"name":"CreateServiceAccountKey","arguments":{"which":"mint"}}}`)
|
||||
e, refused := res["error"].(map[string]any)
|
||||
if !refused {
|
||||
t.Fatalf("tools/call CreateServiceAccountKey was DISPATCHED: %v", res)
|
||||
}
|
||||
if code, _ := e["code"].(float64); int(code) != -32602 {
|
||||
t.Errorf("code = %v, want -32602", e["code"])
|
||||
}
|
||||
if msg, _ := e["message"].(string); strings.Contains(strings.ToLower(msg), "refus") ||
|
||||
strings.Contains(strings.ToLower(msg), "denied") ||
|
||||
strings.Contains(strings.ToLower(msg), "polic") {
|
||||
t.Errorf("the refusal message %q distinguishes 'withheld' from 'does not exist', "+
|
||||
"which makes the door an oracle for the surface it just declined to expose", msg)
|
||||
}
|
||||
|
||||
// …and the surviving sibling still runs, so the test is not passing because
|
||||
// the door is broken.
|
||||
ok := rpc(t, h, `{"jsonrpc":"2.0","id":4,"method":"tools/call",`+
|
||||
`"params":{"name":"post_v1_chat_completions","arguments":{"which":"hello"}}}`)
|
||||
content, _ := ok["content"].([]any)
|
||||
if len(content) == 0 {
|
||||
t.Fatalf("the product tool did not run: %v", ok)
|
||||
}
|
||||
first, _ := content[0].(map[string]any)
|
||||
if text, _ := first["text"].(string); !strings.Contains(text, `"which":"hello"`) {
|
||||
t.Fatalf("tools/call post_v1_chat_completions lost its arguments: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheProductSurfaceLeadsTheList is mechanism (b) end to end.
|
||||
//
|
||||
// The child's own projection is sorted by name (zip mcpTools), and by that order
|
||||
// every PascalCase console op precedes every product op — 'C' < 'p' in ASCII.
|
||||
// The door must reverse that.
|
||||
func TestTheProductSurfaceLeadsTheList(t *testing.T) {
|
||||
kid := startNamed(t, "console",
|
||||
"AgentCheckIn", "GetAlerts", "GetUser", "GetUserPreference", "AuthzCheck",
|
||||
"post_v1_chat_completions", "get_v1_models", "post_v1_code_ask")
|
||||
h := host(t, []string{"console"}, map[string]*child{"console": kid})
|
||||
|
||||
got := order(t, h)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("the door listed nothing")
|
||||
}
|
||||
if got[0] != "post_v1_chat_completions" {
|
||||
t.Errorf("the first tool is %q; chat leads the product surface", got[0])
|
||||
}
|
||||
at := func(name string) int {
|
||||
for i, n := range got {
|
||||
if n == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
t.Fatalf("%q is missing from %v", name, got)
|
||||
return -1
|
||||
}
|
||||
for _, product := range []string{"post_v1_chat_completions", "get_v1_models", "post_v1_code_ask"} {
|
||||
for _, console := range []string{"AgentCheckIn", "GetAlerts", "GetUser", "GetUserPreference", "AuthzCheck"} {
|
||||
if at(product) > at(console) {
|
||||
t.Errorf("%q (%d) comes after %q (%d) — a truncating client keeps the console, not the product",
|
||||
product, at(product), console, at(console))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Within a bucket the order is still alphabetical, so the list is stable.
|
||||
if at("AgentCheckIn") > at("AuthzCheck") {
|
||||
t.Error("the tail is not alphabetical; a list that reorders itself churns every client's cache")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheDoorSAYSHowMuchItWithheld.
|
||||
//
|
||||
// This package's whole thesis is that a silently shortened list is the same
|
||||
// defect as a stale one: the caller cannot tell "serves nothing" from "did not
|
||||
// answer" — or, now, from "was not allowed to say". A policy that shortens the
|
||||
// list quietly would reintroduce exactly that, so the count and the rule ride on
|
||||
// _meta beside the outage report.
|
||||
func TestTheDoorSAYSHowMuchItWithheld(t *testing.T) {
|
||||
kid := startNamed(t, "console", append(append([]string{}, dangerous...), useful...)...)
|
||||
h := host(t, []string{"console"}, map[string]*child{"console": kid})
|
||||
|
||||
res := rpc(t, h, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||
meta, ok := res["_meta"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("tools/list withheld %d tools and said nothing: %v", len(dangerous), res)
|
||||
}
|
||||
row, ok := meta[fleet.Refused].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("_meta has no %q: %v", fleet.Refused, meta)
|
||||
}
|
||||
if n, _ := row["count"].(float64); int(n) != len(dangerous) {
|
||||
t.Errorf("_meta[%q].count = %v, want %d", fleet.Refused, row["count"], len(dangerous))
|
||||
}
|
||||
if rule, _ := row["rule"].(string); rule != fleet.TheRule {
|
||||
t.Errorf("_meta[%q].rule = %q; an operator who wonders where a tool went must be able to read why", fleet.Refused, rule)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusalAndOutageAreREPORTEDTOGETHER: the two _meta keys are independent
|
||||
// facts about one answer and must not overwrite each other. They did, in the
|
||||
// first draft of this change — one map literal, assigned twice.
|
||||
func TestRefusalAndOutageAreREPORTEDTOGETHER(t *testing.T) {
|
||||
kids := map[string]*child{"console": startNamed(t, "console", "CreateUser", "post_v1_chat_completions")}
|
||||
h := host(t, []string{"console", "beta"}, kids) // beta has no instance
|
||||
|
||||
res := rpc(t, h, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||
meta, _ := res["_meta"].(map[string]any)
|
||||
if _, ok := meta[fleet.Refused]; !ok {
|
||||
t.Errorf("_meta lost the refusal report: %v", meta)
|
||||
}
|
||||
if _, ok := meta[fleet.Unavailable]; !ok {
|
||||
t.Errorf("_meta lost the outage report: %v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAQuietFleetReportsNoMetaAtAll: the keys appear only when they say
|
||||
// something. A `_meta` present on every answer is noise a client learns to skip.
|
||||
func TestAQuietFleetReportsNoMetaAtAll(t *testing.T) {
|
||||
kids := map[string]*child{"ai": startNamed(t, "ai", "post_v1_chat_completions", "get_v1_models")}
|
||||
h := host(t, []string{"ai"}, kids)
|
||||
res := rpc(t, h, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||
if m, present := res["_meta"]; present {
|
||||
t.Errorf("nothing was withheld and nothing was down, but _meta = %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheGateIsNotAHeaderTrick: the child answers tools/list for the CALLER, so
|
||||
// a client could try to influence what it is offered. Nothing about the door's
|
||||
// refusal reads the request, and this pins that: the same fleet, asked with a
|
||||
// bearer token, an admin-looking header, and nothing at all, projects the same
|
||||
// tools. (It also documents the finding this change did NOT fix — see the note
|
||||
// on transport auth in the report: a bogus bearer is accepted today because
|
||||
// there is no authentication at this door at all.)
|
||||
func TestTheGateIsNotAHeaderTrick(t *testing.T) {
|
||||
kid := startNamed(t, "console", append(append([]string{}, dangerous...), useful...)...)
|
||||
h := host(t, []string{"console"}, map[string]*child{"console": kid})
|
||||
|
||||
base := order(t, h)
|
||||
for _, hdr := range [][2]string{
|
||||
{"Authorization", "Bearer definitely-not-a-real-token"},
|
||||
{"X-Hanzo-Admin", "true"},
|
||||
{"X-Forwarded-User", "z@hanzo.ai"},
|
||||
} {
|
||||
req, err := http.NewRequest("POST", "http://cloud/v1/mcp",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(hdr[0], hdr[1])
|
||||
resp, err := h.Test(req, zip.TestConfig{Timeout: 60 * time.Second, FailOnTimeout: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
var env struct {
|
||||
Result map[string]any `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("%s: %v", hdr[0], err)
|
||||
}
|
||||
got := offered(env.Result)
|
||||
if strings.Join(got, ",") != strings.Join(base, ",") {
|
||||
t.Errorf("%s: %v changed the projection to %v", hdr[0], hdr[1], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// ONE TOOL PER SUBSYSTEM, with the operation as an argument.
|
||||
//
|
||||
// [rank] answered half of the truncation problem: a client that keeps 128 tools
|
||||
// should keep the useful ones. It cannot answer the other half. Measured at
|
||||
// https://api.hanzo.ai/v1/mcp on the deployed door, tools/list is 1,189 tools in
|
||||
// 977,636 bytes — about 244,000 tokens to merely ENUMERATE what can be called,
|
||||
// which no model holds, and Slack keeps the first 128 of them, so 1,061
|
||||
// operations are unreachable however well they are ordered. Ordering a list
|
||||
// nobody can read is a preference applied to a broken surface.
|
||||
//
|
||||
// The surface is what is wrong. MCP's unit is a TOOL, and this fleet's unit is
|
||||
// an OPERATION, and there are two orders of magnitude between them. So the door
|
||||
// projects one tool per SUBSYSTEM and carries the operation in an argument:
|
||||
//
|
||||
// hanzo_git {"op":"post_v1_git_repos","input":{…}}
|
||||
//
|
||||
// The `op` enum carries NAMES ONLY. That is the whole saving — the 977 KB is
|
||||
// almost entirely input schemas, and a schema is only needed for the ONE
|
||||
// operation a model has chosen. [Describe] is where it fetches that one, which
|
||||
// makes this the ordinary search-then-fetch shape rather than a truncation:
|
||||
// nothing is hidden, every surviving operation is named in exactly one enum and
|
||||
// callable through exactly one tool.
|
||||
//
|
||||
// Three properties this must not lose, and how it keeps them:
|
||||
//
|
||||
// - THE GATE. [Door.gather] refuses a name before it writes the routing table,
|
||||
// and that is still the only gate. A refused name never reaches [group], so
|
||||
// it is in no enum; never reaches [Door.ownerOf], so [Door.call] cannot
|
||||
// dispatch it through an envelope any more than it could directly; and
|
||||
// [Door.describe] answers out of the same gathered set, so it cannot be read
|
||||
// either. One rule, one place, three paths through it.
|
||||
// - ONE DISPATCH. The envelope is a DECODING, not a second route: it yields
|
||||
// the (name, message) a direct tools/call carries, and [Door.call] runs the
|
||||
// same owner lookup and the same hop on both.
|
||||
// - NOTHING REMEMBERED. Describe re-asks. A descriptor kept between requests
|
||||
// would be plugin/<app>/mcp.json again — the committed catalogue this
|
||||
// package exists to delete — and it could go stale in the one way that
|
||||
// matters, by describing an operation the rule has since refused.
|
||||
|
||||
// groupPrefix namespaces the tools this door composes ITSELF, as opposed to the
|
||||
// ones its children declare. A child's operation id is either `<method>_<path>`
|
||||
// or a declared PascalCase verb, so nothing a subsystem serves lands in here.
|
||||
const groupPrefix = "hanzo_"
|
||||
|
||||
// Describe is the door's own tool: the input schema of ONE operation, by name.
|
||||
//
|
||||
// It is the fetch half of the surface — the enums say what exists, this says
|
||||
// what an operation takes — and it is exported because the fleet's own agent
|
||||
// runs are clients of this door like any other (apps/agents/door.go).
|
||||
const Describe = groupPrefix + "describe"
|
||||
|
||||
// group projects the surviving operations as one tool per OWNING app, behind
|
||||
// [Describe].
|
||||
//
|
||||
// The apps are ordered by the best [rank] among the operations they own, then by
|
||||
// name: the same preference the flat list had, lifted one level, so a client
|
||||
// with a tiny window still meets chat before it meets the console. Within an app
|
||||
// the enum is in the order gather sorted it — (rank, name) — so the head of an
|
||||
// enum is that subsystem's product surface too.
|
||||
//
|
||||
// [Describe] leads, and that is the one place [rank]'s preference is overruled
|
||||
// rather than applied. It is not more important than chat; it is what makes
|
||||
// every other tool USABLE, since the enums carry names and no schemas. A
|
||||
// truncating client drops the tail, and a client left holding 128 enums and no
|
||||
// way to read one of them has a surface it cannot fill in — so the tool the rest
|
||||
// depends on cannot be in the tail.
|
||||
//
|
||||
// Never nil, and never empty: `"tools": null` is a client-visible difference
|
||||
// from an empty fleet, and [Describe] is offered even by a fleet that is
|
||||
// serving nothing, because "one way to ask" does not depend on how much there
|
||||
// is to ask about.
|
||||
func group(all []named) []map[string]any {
|
||||
ops := map[string][]string{}
|
||||
best := map[string]int{}
|
||||
for _, t := range all {
|
||||
if r := rank(t.name); len(ops[t.app]) == 0 || r < best[t.app] {
|
||||
best[t.app] = r
|
||||
}
|
||||
ops[t.app] = append(ops[t.app], t.name)
|
||||
}
|
||||
apps := make([]string, 0, len(ops))
|
||||
for a := range ops {
|
||||
apps = append(apps, a)
|
||||
}
|
||||
sort.Slice(apps, func(i, j int) bool {
|
||||
if best[apps[i]] != best[apps[j]] {
|
||||
return best[apps[i]] < best[apps[j]]
|
||||
}
|
||||
return apps[i] < apps[j]
|
||||
})
|
||||
|
||||
out := make([]map[string]any, 0, len(apps)+1)
|
||||
out = append(out, describeTool())
|
||||
for _, a := range apps {
|
||||
out = append(out, subsystemTool(a, ops[a]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// subsystemTool is one app's whole operation set as a single MCP tool.
|
||||
func subsystemTool(app string, ops []string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": groupPrefix + app,
|
||||
"description": app + ": " + strconv.Itoa(len(ops)) + " operations. Name one in \"op\" and pass " +
|
||||
"that operation's own arguments in \"input\". " + Describe + " returns an operation's input schema.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"op": map[string]any{"type": "string", "enum": ops},
|
||||
"input": map[string]any{"type": "object", "description": "arguments for the chosen op"},
|
||||
},
|
||||
"required": []string{"op"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func describeTool() map[string]any {
|
||||
return map[string]any{
|
||||
"name": Describe,
|
||||
"description": "The description and input schema of ONE operation, named as it appears in a " +
|
||||
"subsystem tool's \"op\" enum. Read it before filling \"input\".",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"op": map[string]any{"type": "string"}},
|
||||
"required": []string{"op"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// composed reports whether a tools/call names one of THIS door's own tools
|
||||
// rather than an operation a child declared. See [groupPrefix].
|
||||
func composed(tool string) bool { return strings.HasPrefix(tool, groupPrefix) }
|
||||
|
||||
// envelope is what a subsystem tool carries: the operation to run, and that
|
||||
// operation's own arguments.
|
||||
type envelope struct {
|
||||
Op string `json:"op"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
}
|
||||
|
||||
// unwrap reads the envelope back into what a DIRECT tools/call for the same
|
||||
// operation is: its name, and the canonical message that runs it. ok is false
|
||||
// when the model named a subsystem without naming an operation in it.
|
||||
//
|
||||
// The operation's arguments are carried UNPARSED. They belong to the subsystem
|
||||
// that declared the schema, which is the only thing that knows how to read them,
|
||||
// and re-encoding them here would be this process having an opinion about a
|
||||
// shape it does not own.
|
||||
func unwrap(id, args json.RawMessage) (op string, msg []byte, ok bool) {
|
||||
var e envelope
|
||||
if err := json.Unmarshal(args, &e); err != nil || e.Op == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
if len(e.Input) == 0 {
|
||||
e.Input = json.RawMessage("{}")
|
||||
}
|
||||
msg, err := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": idOrNull(id),
|
||||
"method": "tools/call",
|
||||
"params": map[string]any{"name": e.Op, "arguments": e.Input},
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
return e.Op, msg, true
|
||||
}
|
||||
|
||||
// describe answers the one question a surface of names leaves open: what does
|
||||
// this operation take?
|
||||
//
|
||||
// It ASKS — a fresh [Door.gather], which is also the gate — and hands back the
|
||||
// owning subsystem's OWN descriptor bytes, the same ones the flat list used to
|
||||
// carry. So an operation is describable exactly when it is listable and exactly
|
||||
// when it is callable: there is one set, computed one way, and no third answer.
|
||||
func (d *Door) describe(c *zip.Ctx, req message, args json.RawMessage) error {
|
||||
var in struct {
|
||||
Op string `json:"op"`
|
||||
}
|
||||
_ = json.Unmarshal(args, &in)
|
||||
|
||||
tools, _, _ := d.gather(c)
|
||||
for _, t := range tools {
|
||||
if t.name == in.Op {
|
||||
return c.JSON(200, rpcResult(req.ID, map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": string(t.raw)}},
|
||||
}))
|
||||
}
|
||||
}
|
||||
// The same answer for "nobody serves it" and "policy withheld it", for the
|
||||
// same reason [Door.call] gives one answer for both: naming which it was
|
||||
// would turn the door into an oracle for the surface it just declined to
|
||||
// expose.
|
||||
return c.JSON(200, rpcErr(req.ID, -32602, "unknown tool: "+in.Op))
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package fleet_test
|
||||
|
||||
// The grouped surface, over the wire, at the scale that broke the flat one.
|
||||
//
|
||||
// The measurement these tests exist to hold is not a ratio someone computed. It
|
||||
// was taken from the deployed door:
|
||||
//
|
||||
// $ curl -s https://api.hanzo.ai/v1/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
||||
// 200 977636 bytes 1189 tools (2026-08-06, _meta: 134 refused, 31 apps unavailable)
|
||||
//
|
||||
// ~244,000 tokens to enumerate what can be called, and Slack keeps 128 of them.
|
||||
// So [liveFlatBytes] below is a real number from a real server, and
|
||||
// TestTheWholeFleetFitsInAModelsHead puts the SAME fleet through the new door
|
||||
// and prints what it costs now.
|
||||
//
|
||||
// "The same fleet" is literal: the corpus is plugin/*/openapi.json — each
|
||||
// subsystem's own spec, written by its own binary, which is where its operation
|
||||
// ids come from in the first place. Names someone invented for a test would
|
||||
// measure a fleet that does not exist.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/fleet"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// liveFlatBytes and liveFlatTools are the deployed door's flat tools/list, as
|
||||
// measured. See the file comment.
|
||||
const (
|
||||
liveFlatBytes = 977636
|
||||
liveFlatTools = 1189
|
||||
)
|
||||
|
||||
// slackKeeps is the cap that makes this a correctness problem rather than a
|
||||
// verbosity one: a client that saves the first N tools makes everything after N
|
||||
// permanently unreachable.
|
||||
const slackKeeps = 128
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the fleet's own operation corpus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// corpus is every operation id this fleet declares, by the subsystem that
|
||||
// declares it, read from the plugins' own OpenAPI documents.
|
||||
func corpus(t *testing.T) map[string][]string {
|
||||
t.Helper()
|
||||
specs, err := filepath.Glob(filepath.Join("..", "plugin", "*", "openapi.json"))
|
||||
if err != nil || len(specs) == 0 {
|
||||
t.Fatalf("no plugin specs at ../plugin/*/openapi.json (%v): the corpus is this fleet's own ops, not invented ones", err)
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for _, path := range specs {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var doc struct {
|
||||
Paths map[string]map[string]struct {
|
||||
OperationID string `json:"operationId"`
|
||||
} `json:"paths"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
app := filepath.Base(filepath.Dir(path))
|
||||
seen := map[string]bool{}
|
||||
for _, methods := range doc.Paths {
|
||||
for _, op := range methods {
|
||||
if op.OperationID == "" || seen[op.OperationID] {
|
||||
continue
|
||||
}
|
||||
seen[op.OperationID] = true
|
||||
out[app] = append(out[app], op.OperationID)
|
||||
}
|
||||
}
|
||||
sort.Strings(out[app])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// serving brings up one child per app, each declaring the operation ids given,
|
||||
// and returns a door over all of them.
|
||||
//
|
||||
// The routes are this test's, the ids are the fleet's: zip derives a tool name
|
||||
// from the route only when nobody declared one, and every op here declares.
|
||||
func serving(t *testing.T, by map[string][]string) *zip.App {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
kids := map[string]*child{}
|
||||
apps := make([]string, 0, len(by))
|
||||
for app := range by {
|
||||
apps = append(apps, app)
|
||||
}
|
||||
sort.Strings(apps) // the manifest's order is arbitrary here; a stable one is not
|
||||
for _, app := range apps {
|
||||
kids[app] = serve(t, dir, app, by[app])
|
||||
}
|
||||
return host(t, apps, kids)
|
||||
}
|
||||
|
||||
func serve(t *testing.T, dir, name string, ops []string) *child {
|
||||
t.Helper()
|
||||
sock := filepath.Join(dir, name+".sock")
|
||||
a := zip.New(zip.Config{AppName: name, DisableStartupMessage: true})
|
||||
for i, id := range ops {
|
||||
zip.Post(a, "/v1/"+name+"/op"+strconv.Itoa(i), func(_ context.Context, in *thingIn) (*thingOut, error) {
|
||||
return &thingOut{App: name, Which: in.Which}, nil
|
||||
}, zip.WithOperationID(id), zip.WithSummary("what "+name+" does at "+id))
|
||||
}
|
||||
go func() { _ = a.Listen(sock) }()
|
||||
t.Cleanup(func() { _ = a.Shutdown() })
|
||||
waitFor(t, sock)
|
||||
return &child{name: name, addr: sock, app: a}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// what the door publishes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestTheDoorPublishesOneToolPerSubsystem is the shape of the answer: a tool per
|
||||
// app that has something to offer, plus hanzo_describe. Nothing else.
|
||||
func TestTheDoorPublishesOneToolPerSubsystem(t *testing.T) {
|
||||
h := serving(t, map[string][]string{
|
||||
"ai": {"post_v1_chat_completions", "get_v1_models"},
|
||||
"git": {"post_v1_git_repos", "get_v1_git_repos"},
|
||||
"iam": {"CreateUser", "DeleteUser"}, // every op refused: no tool at all
|
||||
"quiet": {}, // nothing to offer: no tool at all
|
||||
})
|
||||
res := rpc(t, h, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||
|
||||
got := names(res)
|
||||
want := []string{fleet.Describe, "hanzo_ai", "hanzo_git"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("the door publishes %v, want %v", got, want)
|
||||
}
|
||||
// describe leads because everything else is unusable without it; the
|
||||
// subsystems then follow in productStems order, chat before git.
|
||||
if got[0] != fleet.Describe {
|
||||
t.Errorf("the first tool is %q; the tool a truncated surface cannot do without leads", got[0])
|
||||
}
|
||||
// Within an enum the order is gather's: rank first (chat before models),
|
||||
// then name inside a bucket (both git ops share one).
|
||||
if ops := offered(res); strings.Join(ops, ",") !=
|
||||
"post_v1_chat_completions,get_v1_models,get_v1_git_repos,post_v1_git_repos" {
|
||||
t.Errorf("the enums carry %v; within a subsystem the product surface still leads", ops)
|
||||
}
|
||||
// Names only. The 977 KB was the schemas, so an enum that carried them would
|
||||
// have moved the problem rather than solved it.
|
||||
for _, tl := range published(res) {
|
||||
if b, _ := json.Marshal(tl); strings.Contains(string(b), `"which"`) {
|
||||
t.Errorf("a published tool carries an operation's own schema: %s", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheWholeFleetFitsInAModelsHead is the measurement, over this fleet's own
|
||||
// operation corpus, through the real door, on the wire.
|
||||
func TestTheWholeFleetFitsInAModelsHead(t *testing.T) {
|
||||
by := corpus(t)
|
||||
declared := 0
|
||||
for _, ops := range by {
|
||||
declared += len(ops)
|
||||
}
|
||||
h := serving(t, by)
|
||||
|
||||
res := rpc(t, h, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||
body, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tools, ops := names(res), offered(res)
|
||||
|
||||
// Every published tool is ONE subsystem of the corpus, and every name in its
|
||||
// enum is an operation THAT subsystem declared. Nothing leaks between apps,
|
||||
// and nothing is invented.
|
||||
owns := map[string]map[string]bool{}
|
||||
for app, ids := range by {
|
||||
owns[app] = map[string]bool{}
|
||||
for _, id := range ids {
|
||||
owns[app][id] = true
|
||||
}
|
||||
}
|
||||
subsystems := 0
|
||||
for _, tl := range published(res) {
|
||||
m, _ := tl.(map[string]any)
|
||||
name, _ := m["name"].(string)
|
||||
if name == fleet.Describe {
|
||||
continue
|
||||
}
|
||||
subsystems++
|
||||
app := strings.TrimPrefix(name, "hanzo_")
|
||||
if owns[app] == nil {
|
||||
t.Fatalf("the door published %q and no such subsystem is in the corpus", name)
|
||||
}
|
||||
for _, op := range offered(map[string]any{"tools": []any{tl}}) {
|
||||
if !owns[app][op] {
|
||||
t.Fatalf("%s offers %q, which %s does not declare — the grouping is not by owner", name, op, app)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(tools) != subsystems+1 {
|
||||
t.Errorf("published %d tools for %d subsystems; the surface is one per subsystem plus %s",
|
||||
len(tools), subsystems, fleet.Describe)
|
||||
}
|
||||
// Reachable, not merely listed: an op in two enums would be ambiguous, an op
|
||||
// in none would be lost. This is what makes the change a smaller surface and
|
||||
// not a shorter one.
|
||||
once := map[string]bool{}
|
||||
for _, n := range ops {
|
||||
if once[n] {
|
||||
t.Fatalf("%q appears in two enums", n)
|
||||
}
|
||||
once[n] = true
|
||||
}
|
||||
|
||||
if len(tools) >= slackKeeps {
|
||||
t.Fatalf("the door publishes %d tools and a client keeps %d — the cap is still binding", len(tools), slackKeeps)
|
||||
}
|
||||
if tools[0] != fleet.Describe {
|
||||
t.Errorf("the first tool is %q; a client that truncates must keep the one tool the enums cannot be read without", tools[0])
|
||||
}
|
||||
// The claim is per OPERATION, because this corpus is the whole fleet and the
|
||||
// baseline was taken while 31 subsystems were down: a surface of names must
|
||||
// cost an order of magnitude less per op than a surface of schemas.
|
||||
was, now := float64(liveFlatBytes)/float64(liveFlatTools), float64(len(body))/float64(len(ops))
|
||||
if now > was/10 {
|
||||
t.Fatalf("an operation costs %.0f bytes to enumerate, against %.0f flat", now, was)
|
||||
}
|
||||
|
||||
t.Logf("MEASURED — the fleet's own corpus (plugin/*/openapi.json), one child per subsystem:")
|
||||
t.Logf(" operations declared %5d across %d subsystems", declared, len(by))
|
||||
t.Logf(" operations offered %5d (%d withheld by refuse())", len(ops), declared-len(ops))
|
||||
t.Logf(" BEFORE flat tools/list %5d tools %8d bytes %6.0f B/op [api.hanzo.ai, 2026-08-06]",
|
||||
liveFlatTools, liveFlatBytes, was)
|
||||
t.Logf(" AFTER this tools/list %5d tools %8d bytes %6.0f B/op", len(tools), len(body), now)
|
||||
t.Logf(" ratio %5.1fx fewer tools, %.1fx fewer bytes for %.1fx MORE operations (%.0fx per op)",
|
||||
float64(liveFlatTools)/float64(len(tools)), float64(liveFlatBytes)/float64(len(body)),
|
||||
float64(len(ops))/float64(liveFlatTools), was/now)
|
||||
t.Logf(" headroom %5d subsystems before a 128-tool client truncates again", slackKeeps-len(tools))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// what a call through one does
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestASubsystemToolDispatchesExactlyAsTheFlatCallDid: the envelope is a
|
||||
// decoding, not a second route. Same child, same handler, same arguments, same
|
||||
// reply — asserted by comparing the two replies to each other rather than to a
|
||||
// string, so a change to either path has to change both.
|
||||
func TestASubsystemToolDispatchesExactlyAsTheFlatCallDid(t *testing.T) {
|
||||
kids := map[string]*child{"alpha": start(t, "alpha", 2), "beta": start(t, "beta", 2)}
|
||||
h := host(t, []string{"alpha", "beta"}, kids)
|
||||
|
||||
flat := rpc(t, h, `{"jsonrpc":"2.0","id":7,"method":"tools/call",`+
|
||||
`"params":{"name":"beta_opb","arguments":{"which":"x"}}}`)
|
||||
grouped := rpc(t, h, `{"jsonrpc":"2.0","id":7,"method":"tools/call",`+
|
||||
`"params":{"name":"hanzo_beta","arguments":{"op":"beta_opb","input":{"which":"x"}}}}`)
|
||||
|
||||
want, _ := json.Marshal(flat)
|
||||
got, _ := json.Marshal(grouped)
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("hanzo_beta{op:beta_opb} answered\n %s\nand the direct call answered\n %s", got, want)
|
||||
}
|
||||
if text := textOf(t, grouped); !strings.Contains(text, `"app":"beta"`) || !strings.Contains(text, `"which":"x"`) {
|
||||
t.Fatalf("neither call reached beta's own handler with its arguments: %q", text)
|
||||
}
|
||||
if isErr, _ := grouped["isError"].(bool); isErr {
|
||||
t.Fatalf("the grouped call reported an error: %v", grouped)
|
||||
}
|
||||
}
|
||||
|
||||
// textOf is the text of an MCP tool result — the child's own reply, which for
|
||||
// these fixtures is its JSON output.
|
||||
func textOf(t *testing.T, res map[string]any) string {
|
||||
t.Helper()
|
||||
content, _ := res["content"].([]any)
|
||||
if len(content) == 0 {
|
||||
t.Fatalf("no content in %v", res)
|
||||
}
|
||||
first, _ := content[0].(map[string]any)
|
||||
text, _ := first["text"].(string)
|
||||
return text
|
||||
}
|
||||
|
||||
// TestASubsystemToolWithNoOpSaysWhatItNeeds: a model that named the subsystem
|
||||
// and forgot the operation gets told, and nothing is dispatched.
|
||||
func TestASubsystemToolWithNoOpSaysWhatItNeeds(t *testing.T) {
|
||||
h := host(t, []string{"alpha"}, map[string]*child{"alpha": start(t, "alpha", 1)})
|
||||
res := rpc(t, h, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"hanzo_alpha","arguments":{}}}`)
|
||||
e, ok := res["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("an envelope with no op must be refused, got %v", res)
|
||||
}
|
||||
if code, _ := e["code"].(float64); int(code) != -32602 {
|
||||
t.Errorf("code = %v, want -32602", e["code"])
|
||||
}
|
||||
if msg, _ := e["message"].(string); !strings.Contains(msg, `"op"`) {
|
||||
t.Errorf("the message %q does not tell the model what to send", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnUnservedOpInAnEnvelopeIsRefusedNotForwarded: the envelope does not make
|
||||
// the door credulous. A name nobody listed is the same -32602 it always was.
|
||||
func TestAnUnservedOpInAnEnvelopeIsRefusedNotForwarded(t *testing.T) {
|
||||
h := host(t, []string{"alpha"}, map[string]*child{"alpha": start(t, "alpha", 1)})
|
||||
res := rpc(t, h, `{"jsonrpc":"2.0","id":3,"method":"tools/call",`+
|
||||
`"params":{"name":"hanzo_alpha","arguments":{"op":"ghost_op","input":{}}}}`)
|
||||
if _, refused := res["error"].(map[string]any); !refused {
|
||||
t.Fatalf("hanzo_alpha forwarded an operation nobody serves: %v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// describe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestDescribeReturnsTheOWNERsOwnSchema: the fetch half. What comes back is the
|
||||
// subsystem's own descriptor — byte for byte what its registry projects — so a
|
||||
// model that reads it fills the arguments the tool actually declared.
|
||||
func TestDescribeReturnsTheOWNERsOwnSchema(t *testing.T) {
|
||||
kid := start(t, "alpha", 2)
|
||||
h := host(t, []string{"alpha"}, map[string]*child{"alpha": kid})
|
||||
|
||||
res := rpc(t, h, `{"jsonrpc":"2.0","id":5,"method":"tools/call",`+
|
||||
`"params":{"name":"`+fleet.Describe+`","arguments":{"op":"alpha_opb"}}}`)
|
||||
content, _ := res["content"].([]any)
|
||||
if len(content) == 0 {
|
||||
t.Fatalf("%s returned no content: %v", fleet.Describe, res)
|
||||
}
|
||||
first, _ := content[0].(map[string]any)
|
||||
text, _ := first["text"].(string)
|
||||
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &got); err != nil {
|
||||
t.Fatalf("%s returned %q, which is not a tool descriptor: %v", fleet.Describe, text, err)
|
||||
}
|
||||
var want map[string]any
|
||||
for _, tl := range kid.app.MCPTools() {
|
||||
if tl["name"] == "alpha_opb" {
|
||||
want = tl
|
||||
}
|
||||
}
|
||||
if want == nil {
|
||||
t.Fatal("fixture is wrong: alpha does not serve alpha_opb")
|
||||
}
|
||||
wb, _ := json.Marshal(want)
|
||||
gb, _ := json.Marshal(got)
|
||||
if string(gb) != string(wb) {
|
||||
t.Fatalf("describe answered\n %s\nalpha's own registry projects\n %s", gb, wb)
|
||||
}
|
||||
// And it is a REAL schema, not a name echoed back: the op's own argument is in it.
|
||||
if !strings.Contains(text, `"which"`) {
|
||||
t.Errorf("the descriptor carries no input schema: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDescribeOfANameNobodyServesIsRefused.
|
||||
func TestDescribeOfANameNobodyServesIsRefused(t *testing.T) {
|
||||
h := host(t, []string{"alpha"}, map[string]*child{"alpha": start(t, "alpha", 1)})
|
||||
res := rpc(t, h, `{"jsonrpc":"2.0","id":6,"method":"tools/call",`+
|
||||
`"params":{"name":"`+fleet.Describe+`","arguments":{"op":"ghost_op"}}}`)
|
||||
if _, refused := res["error"].(map[string]any); !refused {
|
||||
t.Fatalf("describe answered for an op nobody serves: %v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the gate, through the new surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestARefusedOpIsInvisibleUncallableAndUndescribable is the security bar for
|
||||
// this change, and it is three claims because the grouped surface added two new
|
||||
// ways to ask.
|
||||
//
|
||||
// The child really does serve CreateServiceAccountKey — its registry has it and
|
||||
// a direct call to the child would mint a key — so what is under test is the
|
||||
// door's refusal at every path that now exists:
|
||||
//
|
||||
// tools/list the name is in no subsystem's `op` enum
|
||||
// tools/call hanzo_console{op:CreateServiceAccountKey} does not run it
|
||||
// hanzo_describe its schema cannot be read either
|
||||
//
|
||||
// All three are the same gate: [fleet.Door.gather] refuses before it writes the
|
||||
// routing table, and list, call and describe all read that one gathered set.
|
||||
func TestARefusedOpIsInvisibleUncallableAndUndescribable(t *testing.T) {
|
||||
kid := startNamed(t, "console", "CreateServiceAccountKey", "GetUser", "post_v1_chat_completions")
|
||||
h := host(t, []string{"console"}, map[string]*child{"console": kid})
|
||||
|
||||
// The fixture is only worth something if the child serves it.
|
||||
served := false
|
||||
for _, tl := range kid.app.MCPTools() {
|
||||
if tl["name"] == "CreateServiceAccountKey" {
|
||||
served = true
|
||||
}
|
||||
}
|
||||
if !served {
|
||||
t.Fatal("fixture is wrong: the child does not serve CreateServiceAccountKey, so refusing it proves nothing")
|
||||
}
|
||||
|
||||
// 1. invisible — not in any enum, and not published as a tool of its own.
|
||||
res := rpc(t, h, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||
for _, n := range append(offered(res), names(res)...) {
|
||||
if strings.Contains(n, "ServiceAccountKey") {
|
||||
t.Errorf("the door offers %q — an agent can mint a credential with it", n)
|
||||
}
|
||||
}
|
||||
if raw, _ := json.Marshal(res); strings.Contains(string(raw), "ServiceAccountKey") {
|
||||
t.Errorf("the name survives somewhere in tools/list: %s", raw)
|
||||
}
|
||||
// …and the surviving siblings are still offered, so this is a gate and not a broken door.
|
||||
if got := strings.Join(offered(res), ","); got != "post_v1_chat_completions,GetUser" {
|
||||
t.Errorf("the gate ate a surviving op: enum is %q", got)
|
||||
}
|
||||
|
||||
// 2. not callable through the subsystem tool.
|
||||
call := rpc(t, h, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"hanzo_console",`+
|
||||
`"arguments":{"op":"CreateServiceAccountKey","input":{"which":"mint"}}}}`)
|
||||
e, refused := call["error"].(map[string]any)
|
||||
if !refused {
|
||||
t.Fatalf("hanzo_console DISPATCHED CreateServiceAccountKey: %v", call)
|
||||
}
|
||||
if code, _ := e["code"].(float64); int(code) != -32602 {
|
||||
t.Errorf("code = %v, want -32602", e["code"])
|
||||
}
|
||||
|
||||
// 3. not describable.
|
||||
desc := rpc(t, h, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"`+fleet.Describe+`",`+
|
||||
`"arguments":{"op":"CreateServiceAccountKey"}}}`)
|
||||
if _, refused := desc["error"].(map[string]any); !refused {
|
||||
t.Fatalf("%s handed back the refused op's schema: %v", fleet.Describe, desc)
|
||||
}
|
||||
|
||||
// The refusal says nothing a caller could not have guessed. Naming which of
|
||||
// "withheld" and "does not exist" it was would make the door an oracle for
|
||||
// the surface it just declined to expose.
|
||||
for _, res := range []map[string]any{call, desc} {
|
||||
e, _ := res["error"].(map[string]any)
|
||||
msg, _ := e["message"].(string)
|
||||
for _, tell := range []string{"refus", "denied", "polic", "forbid"} {
|
||||
if strings.Contains(strings.ToLower(msg), tell) {
|
||||
t.Errorf("the refusal message %q distinguishes withheld from absent", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the sibling still runs through the same envelope, so none of the above
|
||||
// passes because the door is broken.
|
||||
ok := rpc(t, h, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"hanzo_console",`+
|
||||
`"arguments":{"op":"post_v1_chat_completions","input":{"which":"hello"}}}}`)
|
||||
content, _ := ok["content"].([]any)
|
||||
if len(content) == 0 {
|
||||
t.Fatalf("the product op did not run through hanzo_console: %v", ok)
|
||||
}
|
||||
first, _ := content[0].(map[string]any)
|
||||
if text, _ := first["text"].(string); !strings.Contains(text, `"which":"hello"`) {
|
||||
t.Fatalf("the grouped call lost its arguments: %q", text)
|
||||
}
|
||||
}
|
||||
+104
-23
@@ -74,10 +74,27 @@ type Door struct {
|
||||
// for the same reason it must not publish its routes.
|
||||
func Mount(host *zip.App, path string, apps []string, at At) *Door {
|
||||
d := &Door{host: host, at: at, apps: apps, owner: map[string]string{}}
|
||||
host.Post(path, d.serve)
|
||||
d.Serve(host, path)
|
||||
return d
|
||||
}
|
||||
|
||||
// Serve publishes THIS door at another address — the same gather, the same
|
||||
// routing table, the same [refuse] gate.
|
||||
//
|
||||
// It exists because the fleet's own subsystems need the door too, and the
|
||||
// address a subsystem can reach is not the edge's. An agent inside `agents`
|
||||
// that asked api.hanzo.ai for its tools would leave the fleet, re-enter through
|
||||
// the front door and arrive back one process away carrying whatever credential
|
||||
// it could find; the host's internal socket is one hop with no edge on it (see
|
||||
// cmd/cloud/wake.go, which is the one caller).
|
||||
//
|
||||
// A SECOND Door over the same children would be a second routing table and,
|
||||
// worse, a second place the curation rule could be applied — or forgotten. This
|
||||
// is the same object reached from another direction: one aggregation, one
|
||||
// policy, one owner map. Which is also why an internal caller cannot be offered
|
||||
// a wider surface than an external one: there is no wider surface to offer.
|
||||
func (d *Door) Serve(on *zip.App, path string) { on.Post(path, d.serve) }
|
||||
|
||||
// message is one JSON-RPC 2.0 envelope, in the shape this door reads it.
|
||||
type message struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
@@ -115,13 +132,25 @@ func (d *Door) serve(c *zip.Ctx) error {
|
||||
// list answers tools/list from the subsystems themselves, and NAMES the ones it
|
||||
// could not reach.
|
||||
func (d *Door) list(c *zip.Ctx, req message) error {
|
||||
tools, down := d.gather(c)
|
||||
result := map[string]any{"tools": tools}
|
||||
tools, down, held := d.gather(c)
|
||||
// ONE TOOL PER SUBSYSTEM, the operation carried in an argument. The flat
|
||||
// projection was 1,189 tools in 977 KB, which no model holds and every client
|
||||
// truncates. See fleet/grouped.go.
|
||||
result := map[string]any{"tools": group(tools)}
|
||||
meta := map[string]any{}
|
||||
if held > 0 {
|
||||
// The same obligation as Unavailable, for a different cause: a list
|
||||
// shortened by POLICY must say so too. See fleet/surface.go.
|
||||
meta[Refused] = map[string]any{"count": held, "rule": TheRule}
|
||||
}
|
||||
if len(down) > 0 {
|
||||
result["_meta"] = map[string]any{Unavailable: down}
|
||||
meta[Unavailable] = down
|
||||
d.host.Logger().Warn("fleet mcp: tools/list is INCOMPLETE — subsystems did not answer",
|
||||
"unavailable", len(down), "apps", len(d.apps), "tools", len(tools))
|
||||
}
|
||||
if len(meta) > 0 {
|
||||
result["_meta"] = meta
|
||||
}
|
||||
return c.JSON(200, rpcResult(req.ID, result))
|
||||
}
|
||||
|
||||
@@ -134,22 +163,52 @@ func (d *Door) list(c *zip.Ctx, req message) error {
|
||||
// none of them serves it.
|
||||
func (d *Door) call(c *zip.Ctx, req message) error {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
_ = json.Unmarshal(req.Params, &p)
|
||||
|
||||
// [Describe] is the door's OWN tool — the fetch half of a surface whose enums
|
||||
// carry names and no schemas — so it is answered here rather than routed.
|
||||
if p.Name == Describe {
|
||||
return d.describe(c, req, p.Arguments)
|
||||
}
|
||||
|
||||
// A subsystem tool is an ENVELOPE over one operation. Unwrapping it yields
|
||||
// exactly the name and message a direct call carries, so everything below is
|
||||
// ONE dispatch for both spellings: the same routing table, the same gate on
|
||||
// the way into it, the same hop, the same reply.
|
||||
msg := c.Fiber().Request().Body()
|
||||
if composed(p.Name) {
|
||||
op, body, ok := unwrap(req.ID, p.Arguments)
|
||||
if !ok {
|
||||
return c.JSON(200, rpcErr(req.ID, -32602, p.Name+` needs {"op":"<operation>","input":{}}`))
|
||||
}
|
||||
p.Name, msg = op, body
|
||||
}
|
||||
|
||||
app := d.ownerOf(p.Name)
|
||||
if app == "" {
|
||||
d.gather(c)
|
||||
app = d.ownerOf(p.Name)
|
||||
}
|
||||
if app == "" {
|
||||
// Either nobody serves it, or refuse() withheld it — and the caller gets
|
||||
// the same answer for both. Telling a client which of the two it hit would
|
||||
// turn the door into an oracle for the identity surface it just declined
|
||||
// to expose.
|
||||
return c.JSON(200, rpcErr(req.ID, -32602, "unknown tool: "+p.Name))
|
||||
}
|
||||
// The caller's OWN message, at the child's own door. The child's registry
|
||||
// invokes it, so the host can only ever name a tool and never invoke one the
|
||||
// child did not declare.
|
||||
ans := Ask(d.at, []string{app}, c.Fiber().Request(), manifest.FrameworkMCPPath)[0]
|
||||
// The caller's own REQUEST — its headers, so identity propagates — carrying
|
||||
// msg, which for a direct call is the caller's own body byte for byte and for
|
||||
// an envelope is that same call spelled out. The child's registry invokes it,
|
||||
// so the host can only ever name a tool and never invoke one the child did
|
||||
// not declare.
|
||||
hop := fasthttp.AcquireRequest()
|
||||
defer fasthttp.ReleaseRequest(hop)
|
||||
c.Fiber().Request().CopyTo(hop)
|
||||
hop.SetBody(msg)
|
||||
ans := Ask(d.at, []string{app}, hop, manifest.FrameworkMCPPath)[0]
|
||||
if ans.Err != nil {
|
||||
// A hop failure is MCP isError content, per the spec: the model reads "this
|
||||
// tool is not available right now" and reacts, where a 503 body is a
|
||||
@@ -163,22 +222,34 @@ func (d *Door) call(c *zip.Ctx, req message) error {
|
||||
return c.Bytes(200, ans.Body)
|
||||
}
|
||||
|
||||
// named is one tool with its name lifted out, so the composed list sorts without
|
||||
// re-parsing and each descriptor is carried VERBATIM — the bytes the child's own
|
||||
// registry projected, never a re-encoding.
|
||||
// named is one tool with its name and its OWNER lifted out, so the composed list
|
||||
// sorts and groups without re-parsing and each descriptor is carried VERBATIM —
|
||||
// the bytes the child's own registry projected, never a re-encoding.
|
||||
type named struct {
|
||||
app string
|
||||
name string
|
||||
raw json.RawMessage
|
||||
}
|
||||
|
||||
// gather asks every app what it serves, right now, and returns the union plus
|
||||
// the outages.
|
||||
// gather asks every app what it serves, right now, and returns the PROJECTABLE
|
||||
// union, the outages, and how many tools policy withheld.
|
||||
//
|
||||
// The request it sends is the CALLER's, with the body replaced by a canonical
|
||||
// tools/list: the headers ride along, so a child whose tools depend on who is
|
||||
// asking answers for this caller, while the body cannot be a tools/call the
|
||||
// discovery path would otherwise execute on every child in the fleet.
|
||||
func (d *Door) gather(c *zip.Ctx) ([]json.RawMessage, []Outage) {
|
||||
//
|
||||
// This is also where the tool surface is GATED, and it is the only place, on
|
||||
// purpose. The routing table [Door.owner] is written here and nowhere else, so a
|
||||
// name that refuse() rejects is never written, is never routable, and a
|
||||
// tools/call naming it gets the same -32602 as a tool that does not exist —
|
||||
// including from a client that cached the name before the rule existed. A filter
|
||||
// applied in list() instead would have been a suggestion.
|
||||
//
|
||||
// It returns the tools themselves rather than their bytes because every caller
|
||||
// needs the OWNER too: list() groups by it (fleet/grouped.go) and describe()
|
||||
// answers out of the same gated set.
|
||||
func (d *Door) gather(c *zip.Ctx) ([]named, []Outage, int) {
|
||||
req := fasthttp.AcquireRequest()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
c.Fiber().Request().CopyTo(req)
|
||||
@@ -188,6 +259,7 @@ func (d *Door) gather(c *zip.Ctx) ([]json.RawMessage, []Outage) {
|
||||
|
||||
var all []named
|
||||
var down []Outage
|
||||
held := 0
|
||||
owner := map[string]string{}
|
||||
for _, a := range Ask(d.at, d.apps, req, manifest.FrameworkMCPPath) {
|
||||
if a.Err != nil {
|
||||
@@ -200,6 +272,11 @@ func (d *Door) gather(c *zip.Ctx) ([]json.RawMessage, []Outage) {
|
||||
continue
|
||||
}
|
||||
for _, t := range tools {
|
||||
// The gate, before the routing table. See fleet/surface.go.
|
||||
if refuse(t.name) {
|
||||
held++
|
||||
continue
|
||||
}
|
||||
// One name is one dispatch, so two owners would make it unroutable. The
|
||||
// manifest's order is the router's order, so the first claimant wins here
|
||||
// exactly as it wins a prefix — and the loser is logged rather than
|
||||
@@ -211,22 +288,26 @@ func (d *Door) gather(c *zip.Ctx) ([]json.RawMessage, []Outage) {
|
||||
continue
|
||||
}
|
||||
owner[t.name] = a.App
|
||||
t.app = a.App
|
||||
all = append(all, t)
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].name < all[j].name })
|
||||
// Product surface FIRST, then alphabetical — because clients truncate, and a
|
||||
// list sorted only by name put 128 o11y console ops in front of every product
|
||||
// tool the fleet has. rank() states the mechanism; nothing is hidden by it.
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
ri, rj := rank(all[i].name), rank(all[j].name)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return all[i].name < all[j].name
|
||||
})
|
||||
|
||||
d.mu.Lock()
|
||||
d.owner = owner
|
||||
d.mu.Unlock()
|
||||
|
||||
// Never nil: `"tools": null` is a client-visible difference from an empty
|
||||
// fleet, and JSON has one way to say "no tools".
|
||||
out := make([]json.RawMessage, 0, len(all))
|
||||
for _, t := range all {
|
||||
out = append(out, t.raw)
|
||||
}
|
||||
return out, down
|
||||
return all, down, held
|
||||
}
|
||||
|
||||
func (d *Door) ownerOf(tool string) string {
|
||||
|
||||
+39
-4
@@ -137,17 +137,52 @@ func rpc(t *testing.T, h *zip.App, body string) map[string]any {
|
||||
return env.Result
|
||||
}
|
||||
|
||||
// listed is the tool NAMES the door reports, sorted.
|
||||
func listed(t *testing.T, h *zip.App) []string {
|
||||
t.Helper()
|
||||
res := rpc(t, h, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||
// offered is every OPERATION the door offers, in the order it offers them.
|
||||
//
|
||||
// The door publishes one tool per subsystem and carries the operations in that
|
||||
// tool's `op` enum (fleet/grouped.go), so the operations are read out of the
|
||||
// enums rather than off the tool names. That is the same question these tests
|
||||
// always asked — "what can be called through this door" — put to the surface
|
||||
// that now answers it. hanzo_describe has no enum and contributes nothing.
|
||||
func offered(res map[string]any) []string {
|
||||
var out []string
|
||||
for _, tl := range published(res) {
|
||||
m, _ := tl.(map[string]any)
|
||||
schema, _ := m["inputSchema"].(map[string]any)
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
op, _ := props["op"].(map[string]any)
|
||||
enum, _ := op["enum"].([]any)
|
||||
for _, n := range enum {
|
||||
s, _ := n.(string)
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// published is the TOOLS the door publishes — the hanzo_<app> envelopes
|
||||
// themselves, not the operations inside them.
|
||||
func published(res map[string]any) []any {
|
||||
tools, _ := res["tools"].([]any)
|
||||
return tools
|
||||
}
|
||||
|
||||
// names lifts the tool names out of published().
|
||||
func names(res map[string]any) []string {
|
||||
tools := published(res)
|
||||
out := make([]string, 0, len(tools))
|
||||
for _, tl := range tools {
|
||||
m, _ := tl.(map[string]any)
|
||||
n, _ := m["name"].(string)
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listed is the operation names the door offers, sorted.
|
||||
func listed(t *testing.T, h *zip.App) []string {
|
||||
t.Helper()
|
||||
out := offered(rpc(t, h, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// What the fleet's door is WILLING to say a tool is, and in what ORDER.
|
||||
//
|
||||
// [Door.gather] asks every subsystem what it serves and returns the union. That
|
||||
// is the right answer to "what exists" and the wrong answer to "what may an
|
||||
// agent call", and until this file the door had no second answer: on
|
||||
// api.hanzo.ai it projected 1,323 tools with zero annotations, zero
|
||||
// readOnlyHint, and no auth at the transport. Two independent facts made that
|
||||
// concrete rather than theoretical:
|
||||
//
|
||||
// - The list is sorted by name and clients TRUNCATE it. Slack keeps the first
|
||||
// 128. Because zip's operation ids are PascalCase for the subsystems that
|
||||
// declare them and `<method>_<path>` for the rest, and 'C' < 'a' in ASCII,
|
||||
// the first 128 names were ALL o11y's declared ids — sorted(names)[127] ==
|
||||
// "GetUserPreference", exactly Slack's last saved tool. Zero product tools
|
||||
// (chat, deploy, run, git, search, code, model) were inside that window.
|
||||
// - Thirty-six of the tools inside it mint or disclose credentials.
|
||||
// CreateServiceAccountKey is, in its own description, "the one time the
|
||||
// secret is ever shown". CreateSessionByEmailPassword, CreateResetPasswordToken,
|
||||
// CreateUser, DeleteUser, CreateAuthDomain sat beside it.
|
||||
//
|
||||
// So the agent surface was the exact complement of the useful one. Two
|
||||
// mechanisms fix that, and they are DIFFERENT mechanisms and stay separate:
|
||||
//
|
||||
// [refuse] removes a tool from the projection entirely — for every client,
|
||||
// from BOTH tools/list and tools/call, because gather is where the
|
||||
// routing table is written and a name that is never written is never
|
||||
// routable. This is a boundary, not a preference.
|
||||
//
|
||||
// [rank] orders what survives, so the flagship product tools occupy the
|
||||
// head of the list a truncating client keeps. This is a preference,
|
||||
// not a boundary: nothing is hidden, only moved.
|
||||
//
|
||||
// Both read the tool NAME and nothing else, because the name is all an MCP tool
|
||||
// descriptor carries that a host can reason about — zip's mcpToolOf projects
|
||||
// exactly {name, description, inputSchema} (zip@v1.25.1 mcp.go:448) and neither
|
||||
// method nor path survives into it. The name is enough, because zip derives it
|
||||
// from the route when nobody declared one: defaultOpID(method, path) is
|
||||
// lower(method) + path with '/'→'_' and braces stripped (zip@v1.25.1
|
||||
// openapi.go:274), so `POST /v1/chat/completions` IS `post_v1_chat_completions`.
|
||||
// A declared id is a verb followed by its object — `CreateServiceAccountKey`.
|
||||
// One tokenizer reads both.
|
||||
|
||||
// Refused is the _meta key under which tools/list reports what it withheld.
|
||||
//
|
||||
// It exists for the same reason [Unavailable] does, and it is the same defect
|
||||
// if it is missing: a silently shortened list cannot be told apart from a fleet
|
||||
// that serves nothing. The door already refuses to shorten quietly for an
|
||||
// outage; refusing to shorten quietly for a POLICY is the same obligation. The
|
||||
// count and the rule travel with the answer, so an operator who wonders where
|
||||
// CreateServiceAccountKey went reads why rather than filing a bug against a
|
||||
// subsystem that is serving it correctly.
|
||||
const Refused = "hanzo.ai/refused"
|
||||
|
||||
// TheRule is the sentence [refuse] implements, carried on the wire under
|
||||
// [Refused] so the answer explains itself.
|
||||
const TheRule = "a tool is not projected when its name discloses a bearer secret at any verb, " +
|
||||
"or when a mutating verb acts on an identity or authority object"
|
||||
|
||||
// refuse reports whether the fleet's door will project a tool at all.
|
||||
//
|
||||
// The rule, in two clauses over the name's words:
|
||||
//
|
||||
// 1. DISCLOSURE. The name says it handles a bearer secret — a token, a
|
||||
// password, a credential, a private key. Refused at EVERY verb, because
|
||||
// reading `GET /v1/o11y/users/{id}/reset_password_tokens` hands the secret
|
||||
// over just as surely as the PUT that mints it. Verb-blindness is the whole
|
||||
// point of this clause.
|
||||
//
|
||||
// 2. AUTHORITY MUTATION. A mutating verb acts on an identity or authority
|
||||
// object — a user, a role, a policy, an invite, a key, a sign-in session, a
|
||||
// service account, an auth domain. Refused. The matching READ is not:
|
||||
// GetRole and GetUser survive, because knowing who holds a role is not the
|
||||
// same act as granting one, and an agent that cannot see the org cannot
|
||||
// reason about it.
|
||||
//
|
||||
// Neither clause is a list of ops. Both are lists of NOUNS and VERBS, so op
|
||||
// 1,324 is classified the day it is written — which is the property a
|
||||
// hand-maintained roster of 36 names cannot have. The words below are the whole
|
||||
// policy; nothing else in this package decides.
|
||||
func refuse(tool string) bool {
|
||||
w := words(tool)
|
||||
if len(w) == 0 {
|
||||
return true // a nameless tool is not routable and not projectable
|
||||
}
|
||||
if discloses(w) {
|
||||
return true
|
||||
}
|
||||
return mutates(w) && authority(w)
|
||||
}
|
||||
|
||||
// discloses is clause 1: the name of a bearer secret, at any verb.
|
||||
//
|
||||
// Two of the words are NOT always secrets, and they are QUALIFIED rather than
|
||||
// dropped, because dropping either one would open the exact hole this file
|
||||
// closes:
|
||||
//
|
||||
// - `token` is also a unit of text to an LLM and a unit of value on a chain.
|
||||
// A counting or identifying neighbour makes it one of those instead, which
|
||||
// is how `post_v1_messages_count_tokens` and `get_v1_validators_tokenId`
|
||||
// survive while `post_v1_iam_oauth_token` does not.
|
||||
// - `session` is a bearer object when NOTHING OWNS IT — when the word before
|
||||
// it is a verb, the version, or an auth marker (`getSession`,
|
||||
// `RotateSession`, `post_v1_ai_signin-sessions`). Owned by a resource, it is
|
||||
// that resource's unit of work and survives, which is what keeps the agent
|
||||
// loop — `post_v1_agents_sessions_by_id_message` and its siblings — on the
|
||||
// agent's own surface. Reading an identity session is refused as hard as
|
||||
// creating one, because the object read back IS the credential.
|
||||
func discloses(w []string) bool {
|
||||
for i, t := range w {
|
||||
switch {
|
||||
case t == "token" || t == "tokens":
|
||||
if !measured(w, i) {
|
||||
return true
|
||||
}
|
||||
case t == "session" || t == "sessions":
|
||||
if unowned(w, i) {
|
||||
return true
|
||||
}
|
||||
case secretNoun[t]:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// measured reports whether `token` at index i is being counted or identified
|
||||
// rather than presented — the nearest real neighbour on either side decides.
|
||||
func measured(w []string, i int) bool {
|
||||
return tokenIsAQuantity[before(w, i)] || tokenIsAQuantity[after(w, i)]
|
||||
}
|
||||
|
||||
// unowned reports whether the session at index i belongs to nobody: the word
|
||||
// before it is a verb, the API version, or an explicit auth marker. See
|
||||
// [discloses].
|
||||
func unowned(w []string, i int) bool {
|
||||
p := before(w, i)
|
||||
return p == "" || p == "v1" || verb[p] || authMarker[p]
|
||||
}
|
||||
|
||||
// mutates reports whether the name's verb CHANGES something.
|
||||
//
|
||||
// It reads every word, not just the first, because the two naming conventions
|
||||
// put the verb in different places: a declared id leads with it
|
||||
// (`CreateServiceAccountKey`), a derived one leads with the HTTP method
|
||||
// (`post_v1_iam_users`) — and for derived names the method IS the verb, which
|
||||
// is why `post`/`put`/`patch`/`delete` are in the same set as `create`/`grant`.
|
||||
func mutates(w []string) bool {
|
||||
for _, t := range w {
|
||||
if mutatingVerb[t] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// authority is clause 2's object test: does this name act on identity?
|
||||
//
|
||||
// Three words need a neighbour before they count, and each for a reason that is
|
||||
// about English rather than about security policy:
|
||||
//
|
||||
// - `account` is a billing relationship far more often than a principal, so
|
||||
// only the pair `service account` counts. CreateServiceAccount does;
|
||||
// CreateAccount, which connects a cloud integration, does not.
|
||||
// - `domain` is a DNS name we SELL. Only `auth domain` counts, which is how
|
||||
// CreateAuthDomain is refused while the whole `/v1/domain` product is not.
|
||||
// - `key` is the hard one, and it is qualified by EXCLUSION rather than by
|
||||
// inclusion — a key is a credential unless its neighbour says it is an
|
||||
// entry in a store or a name in a schema. That direction is deliberate: an
|
||||
// inclusion list ("api key, ssh key, signing key, …") fails OPEN on the
|
||||
// credential nobody thought of, and this clause must fail closed. So
|
||||
// `delete_v1_pubsub_kv_bucket_key` and `patch_v1_tracker_projects_key_issues_num`
|
||||
// survive on their neighbours, while `delete_v1_git_keys_id`,
|
||||
// `post_v1_agents_targets_id_key` and `delete_v1_keys` do not.
|
||||
//
|
||||
// Note what is NOT here. `grant` is a mutating VERB but not an authority noun,
|
||||
// because in this fleet a grant is nearly always money — adminGrantCredit,
|
||||
// post_v1_research_grants. Nothing identity-shaped needs it: GrantRole and
|
||||
// grantPermission are already refused on their objects, so the noun only ever
|
||||
// bought false positives. `owner` was here, and it was wrong: zip renders a path
|
||||
// param as `by_<name>`, so every `/v1/ai/{owner}/{name}` route in the fleet —
|
||||
// 45 of them, the whole ai CRUD surface — reads as an authority mutation on a
|
||||
// word that is really a namespace. `admin` is absent for a different reason:
|
||||
// it names an AUDIENCE, not an identity object, and sweeping it in would make
|
||||
// this a general blast-radius policy rather than the identity boundary it is.
|
||||
// Blast radius on infra and money is a real question and a DIFFERENT one; it
|
||||
// belongs to whatever authenticates the transport, not to a name filter.
|
||||
func authority(w []string) bool {
|
||||
for i, t := range w {
|
||||
switch {
|
||||
case authorityNoun[t]:
|
||||
return true
|
||||
case t == "account" || t == "accounts":
|
||||
if before(w, i) == "service" {
|
||||
return true
|
||||
}
|
||||
case t == "domain" || t == "domains":
|
||||
if before(w, i) == "auth" {
|
||||
return true
|
||||
}
|
||||
case t == "key" || t == "keys":
|
||||
if !keyOfAStore[before(w, i)] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// secretNoun names a thing whose VALUE is the credential. `token` and `session`
|
||||
// are handled separately in [discloses] because they need a neighbour.
|
||||
var secretNoun = set(
|
||||
"password", "passwords",
|
||||
"credential", "credentials",
|
||||
"secret", "secrets",
|
||||
"apikey", "apikeys",
|
||||
"jwt", "jwts",
|
||||
"otp", "totp",
|
||||
"passkey", "passkeys",
|
||||
"privatekey", "privatekeys",
|
||||
"keypair", "keypairs",
|
||||
"mnemonic", "mnemonics",
|
||||
"seedphrase", "seedphrases",
|
||||
)
|
||||
|
||||
// tokenIsAQuantity are the neighbours that turn `token` into a unit rather than
|
||||
// a credential — LLM accounting and chain identifiers.
|
||||
var tokenIsAQuantity = set(
|
||||
"count", "counts", "counting",
|
||||
"usage", "used", "limit", "limits", "budget",
|
||||
"max", "min", "total", "per",
|
||||
"price", "pricing", "cost",
|
||||
"id", "ids", "symbol", "supply", "balance",
|
||||
)
|
||||
|
||||
// keyOfAStore are the neighbours that turn `key` into an entry in a store or a
|
||||
// name in a schema rather than a credential. See [authority] for why this list
|
||||
// runs in the exclusion direction.
|
||||
var keyOfAStore = set(
|
||||
"kv", "bucket", "buckets", "namespace", "namespaces",
|
||||
"value", "values", "map", "cache", "store", "listing", "listings",
|
||||
"project", "projects", "def", "defs", "definition", "definitions",
|
||||
"attribute", "attributes", "field", "fields", "label", "labels",
|
||||
"tag", "tags", "index", "indexes", "column", "columns", "dimension",
|
||||
"partition", "shard", "prefix", "sort", "group", "primary", "foreign",
|
||||
"idempotency", "row", "rows", "entry", "entries", "item", "items", "object",
|
||||
)
|
||||
|
||||
// mutatingVerb is every word that means "this CHANGES something", including the
|
||||
// four HTTP methods that mean it in a derived name. See [mutates].
|
||||
var mutatingVerb = set(
|
||||
// HTTP methods, as they appear in a derived operation id.
|
||||
"post", "put", "patch", "delete",
|
||||
// Declared verbs.
|
||||
"create", "update", "upsert", "set", "add", "insert", "write",
|
||||
"remove", "destroy", "purge", "drop", "reset", "rotate",
|
||||
"grant", "revoke", "assign", "unassign", "issue", "mint", "sign",
|
||||
"invite", "register", "enroll", "provision", "deprovision",
|
||||
"enable", "disable", "activate", "deactivate", "suspend",
|
||||
"promote", "demote", "elevate", "impersonate", "assume",
|
||||
"attach", "detach", "bind", "unbind", "link", "unlink",
|
||||
"login", "logout", "signin", "signout", "signup", "authenticate", "forgot",
|
||||
)
|
||||
|
||||
// verb is every word that leads a name, mutating or not. It exists only for
|
||||
// [unowned]: a session preceded by a VERB is preceded by no resource, and
|
||||
// `getSession` hands back the same credential `createSession` mints.
|
||||
var verb = union(mutatingVerb, set(
|
||||
"get", "head", "options",
|
||||
"list", "read", "fetch", "show", "describe", "find", "search", "query",
|
||||
"count", "stream", "watch", "export", "verify", "check", "validate",
|
||||
))
|
||||
|
||||
// authorityNoun names a thing that CONFERS or CARRIES authority. See [authority].
|
||||
var authorityNoun = set(
|
||||
"user", "users",
|
||||
"role", "roles",
|
||||
"permission", "permissions",
|
||||
"policy", "policies",
|
||||
"member", "members", "membership", "memberships",
|
||||
"principal", "principals",
|
||||
"identity", "identities",
|
||||
"invite", "invites", "invitation", "invitations",
|
||||
"auth", "authz", "authn", "oauth", "oidc", "saml", "sso", "scim", "mfa", "2fa",
|
||||
"webauthn", "superuser", "superusers", "impersonation",
|
||||
"acl", "acls", "rbac", "iam", "kms",
|
||||
)
|
||||
|
||||
// authMarker marks a session as an IDENTITY session. See [unowned].
|
||||
var authMarker = set("signin", "signon", "login", "auth", "oauth", "sso", "user", "email", "cookie", "bearer")
|
||||
|
||||
// filler are the positional words a derived operation id carries that name
|
||||
// nothing: zip renders a path param as `by_<name>`, so the word before `key` in
|
||||
// `delete_v1_store_by_storeid_listing_by_key` is grammar, not context. Every
|
||||
// neighbour test skips them, in ONE place — see [before] and [after].
|
||||
var filler = set("by", "the", "a", "an", "of", "for", "my", "me", "all", "and")
|
||||
|
||||
// productStems is the CURATED PRODUCT SURFACE, in the order it is offered.
|
||||
//
|
||||
// This is the answer to the truncation half of the problem, and the mechanism is
|
||||
// stated here so nobody has to infer it: a client that keeps only the first N
|
||||
// tools keeps the ones in this slice, in this order, because [rank] buckets by
|
||||
// index here and the sort is (bucket, name). Nothing is hidden — every surviving
|
||||
// tool is still in the list, the unlisted ones simply follow.
|
||||
//
|
||||
// The entries are ROUTE STEMS, not tool names, matched against a derived
|
||||
// operation id's path half on a '_' boundary. That is the whole reason the list
|
||||
// is short enough to read: `v1_git` covers all 32 git ops forever, including the
|
||||
// ones written next year, and adding a route under an existing product prefix
|
||||
// promotes it with no edit here. A subsystem that declares its own PascalCase
|
||||
// ids carries no path in its names, so it can never match a stem — which is
|
||||
// correct, because those subsystems (o11y, iam) are the console's surface and
|
||||
// not the agent's.
|
||||
//
|
||||
// The head of the list is deliberately the inference surface: a client with a
|
||||
// tiny window should get chat before it gets anything else.
|
||||
var productStems = []string{
|
||||
"v1_chat", // POST /v1/chat/completions — the flagship
|
||||
"v1_completions", //
|
||||
"v1_responses", //
|
||||
"v1_messages", //
|
||||
"v1_embeddings", //
|
||||
"v1_rerank", //
|
||||
"v1_models", // what can it call
|
||||
"v1_agent", // the agent loop: conversations, presets
|
||||
"v1_agents", // …sessions, runs, targets
|
||||
"v1_code", // code intelligence: ask, context, index, search
|
||||
"v1_search", //
|
||||
"v1_git", // source control
|
||||
"v1_deploy", // ship it
|
||||
"v1_exec", // run it
|
||||
"v1_projects", // …and the things shipped
|
||||
"v1_websearch", //
|
||||
}
|
||||
|
||||
// httpMethod is the leading word of a DERIVED operation id — the half [rank]
|
||||
// strips before matching a stem.
|
||||
var httpMethod = set("get", "post", "put", "patch", "delete", "head", "options")
|
||||
|
||||
// rank is the sort bucket for a tool: its index in [productStems], or the tail.
|
||||
//
|
||||
// A name is ranked by its PATH, which a derived id carries verbatim after the
|
||||
// method word. `post_v1_chat_completions` → "v1_chat_completions" → stem
|
||||
// "v1_chat" matches on the '_' boundary → bucket 0. A name with no method word
|
||||
// (`GetUserPreference`) has no path to match and takes the tail bucket, as does
|
||||
// any route under no product prefix.
|
||||
func rank(tool string) int {
|
||||
head, tail, found := strings.Cut(tool, "_")
|
||||
if !found || !httpMethod[strings.ToLower(head)] {
|
||||
return len(productStems)
|
||||
}
|
||||
tail = strings.ToLower(tail)
|
||||
for i, stem := range productStems {
|
||||
if tail == stem || strings.HasPrefix(tail, stem+"_") {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(productStems)
|
||||
}
|
||||
|
||||
// words splits a tool name into lowercase words under BOTH naming conventions
|
||||
// at once: separators (`_`, `-`, `.`, `/`) and camel/Pascal case boundaries,
|
||||
// with acronym runs kept whole. `CreateLLMScore` → [create llm score];
|
||||
// `delete_v1_ai_signin-sessions_by_owner_by_name` → [delete v1 ai signin
|
||||
// sessions by owner by name]; `GetRolesByUserID` → [get roles by user id].
|
||||
func words(name string) []string {
|
||||
rs := []rune(name)
|
||||
var out []string
|
||||
var cur []rune
|
||||
flush := func() {
|
||||
if len(cur) > 0 {
|
||||
out = append(out, strings.ToLower(string(cur)))
|
||||
cur = cur[:0]
|
||||
}
|
||||
}
|
||||
for i, r := range rs {
|
||||
switch {
|
||||
case r == '_' || r == '-' || r == '.' || r == '/' || r == ' ' || r == ':':
|
||||
flush()
|
||||
case unicode.IsUpper(r):
|
||||
// lower→UPPER starts a word; UPPER→UPPER→lower ends an acronym.
|
||||
if i > 0 && (unicode.IsLower(rs[i-1]) || unicode.IsDigit(rs[i-1])) {
|
||||
flush()
|
||||
} else if i > 0 && unicode.IsUpper(rs[i-1]) && i+1 < len(rs) && unicode.IsLower(rs[i+1]) {
|
||||
flush()
|
||||
}
|
||||
cur = append(cur, r)
|
||||
default:
|
||||
cur = append(cur, r)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// before and after are the nearest MEANINGFUL neighbours of w[i] — the ones a
|
||||
// qualification test asks about — with [filler] skipped. Empty when there is
|
||||
// none, which every caller reads as "no context", never as a match.
|
||||
func before(w []string, i int) string {
|
||||
for j := i - 1; j >= 0; j-- {
|
||||
if !filler[w[j]] {
|
||||
return w[j]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func after(w []string, i int) string {
|
||||
for j := i + 1; j < len(w); j++ {
|
||||
if !filler[w[j]] {
|
||||
return w[j]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func set(words ...string) map[string]bool {
|
||||
m := make(map[string]bool, len(words))
|
||||
for _, w := range words {
|
||||
m[w] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func union(ms ...map[string]bool) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, m := range ms {
|
||||
for k := range m {
|
||||
out[k] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The rule, against the names it was written for.
|
||||
//
|
||||
// Every name below is a REAL operation id from this fleet's own composed
|
||||
// document (openapi.yaml) or from plugin/o11y/openapi.json, with its route
|
||||
// beside it, because a policy tested on names someone invented for the test is a
|
||||
// policy tested on nothing. The o11y ones are specifically the ops MEASURED
|
||||
// inside Slack's 128-tool window on api.hanzo.ai.
|
||||
|
||||
// refusals is the dangerous half: what must never reach an agent.
|
||||
var refusals = []struct{ name, why string }{
|
||||
// --- clause 1, disclosure: the secret is the payload, at any verb. ---
|
||||
{"CreateServiceAccountKey", "POST /v1/o11y/service_accounts/{id}/keys — mints an API key; its own description says this is the one time the secret is shown"},
|
||||
{"CreateSessionByEmailPassword", "POST /v1/o11y/sessions/email_password — logs in as a user"},
|
||||
{"CreateResetPasswordToken", "PUT /v1/o11y/users/{id}/reset_password_tokens — mints a password reset"},
|
||||
{"GetResetPasswordToken", "GET of the same token: READING it is disclosing it, which is why clause 1 is verb-blind"},
|
||||
{"GetResetPasswordTokenDeprecated", "the deprecated spelling of the same disclosure"},
|
||||
{"VerifyResetPasswordToken", "verify still carries the token"},
|
||||
{"ForgotPassword", "POST /v1/o11y/factor_password/forgot"},
|
||||
{"UpdateMyPassword", "PUT /v1/o11y/users/me/factor_password"},
|
||||
{"GetConnectionCredentials", "GET /v1/o11y/cloud_integrations/{p}/credentials — a GET that returns cloud creds"},
|
||||
{"getToken", "POST /v1/iam/tokens/get"},
|
||||
{"addToken", "POST /v1/iam/tokens"},
|
||||
{"listTokens", "GET /v1/iam/tokens"},
|
||||
{"getWebauthnCredential", "POST /v1/iam/webauthn-credentials/get"},
|
||||
{"post_v1_iam_oauth_token", "the OAuth token endpoint"},
|
||||
{"post_v1_iam_registry_token", "a registry pull credential"},
|
||||
{"post_v1_iam_mint-user-keys", "mints keys for a user"},
|
||||
{"get_v1_kms_secrets", "the secret store, read"},
|
||||
{"delete_v1_kms_secrets_by_wildcard1", "the secret store, written"},
|
||||
{"get_v1_functions_secrets", "a function's environment secrets"},
|
||||
// Sessions that nothing owns are identity sessions — read or write.
|
||||
{"getSession", "POST /v1/iam/sessions/get — the session object IS the credential"},
|
||||
{"listSessions", "GET /v1/iam/sessions"},
|
||||
{"createSession", "POST /v1/iam/sessions/create"},
|
||||
{"DeleteSession", "DELETE /v1/o11y/sessions"},
|
||||
{"RotateSession", "POST /v1/o11y/sessions/rotate"},
|
||||
{"post_v1_ai_signin-sessions", "explicitly a sign-in session"},
|
||||
{"get_v1_ai_signin-sessions", "…and reading one hands back what it holds"},
|
||||
|
||||
// --- clause 2, authority mutation: the verb changes who may do what. ---
|
||||
{"CreateUser", "POST /v1/o11y/users"},
|
||||
{"DeleteUser", "DELETE /v1/o11y/users/{id}"},
|
||||
{"UpdateUser", "PUT /v1/o11y/users/{id}"},
|
||||
{"CreateAuthDomain", "POST /v1/o11y/domains — an auth domain, not a DNS one"},
|
||||
{"DeleteAuthDomain", "DELETE /v1/o11y/domains/{id}"},
|
||||
{"CreateRole", "POST /v1/o11y/roles"},
|
||||
{"DeleteRole", "DELETE /v1/o11y/roles/{id}"},
|
||||
{"SetRoleByUserID", "POST /v1/o11y/users/{id}/roles — grants a role"},
|
||||
{"RemoveUserRoleByUserIDAndRoleID", "DELETE of the same"},
|
||||
{"CreateServiceAccount", "POST /v1/o11y/service_accounts — a new principal"},
|
||||
{"CreateServiceAccountRole", "…and its authority"},
|
||||
{"RevokeServiceAccountKey", "DELETE /v1/o11y/service_accounts/{id}/keys/{fid}"},
|
||||
{"CreateInvite", "POST /v1/o11y/invite — adds a person to the org"},
|
||||
{"CreateBulkInvite", "POST /v1/o11y/invite/bulk"},
|
||||
{"CreateIngestionKey", "POST /v1/o11y/gateway/ingestion_keys"},
|
||||
{"CreateRoutePolicy", "POST /v1/o11y/route_policies"},
|
||||
{"post_v1_iam_add-user", "POST /v1/iam/add-user"},
|
||||
{"post_v1_iam_scim_v2_users", "SCIM user provisioning"},
|
||||
{"delete_v1_framework_roles_user_role", "DELETE /v1/framework/roles/{user}/{role}"},
|
||||
{"post_v1_git_keys", "POST /v1/git/keys — an SSH key is a credential even on the git surface"},
|
||||
{"delete_v1_git_keys_id", "and removing one is still key management"},
|
||||
{"post_v1_agents_targets_id_key", "POST /v1/agents/targets/{id}/key — enrols a machine agent"},
|
||||
{"delete_v1_keys", "DELETE /v1/keys — the head resource, so no store owns it"},
|
||||
}
|
||||
|
||||
// survivors is the useful half: what an agent is FOR. Several of these are here
|
||||
// because an earlier draft of the rule refused them — the note says which word
|
||||
// did it, so re-adding that word turns this red.
|
||||
var survivors = []struct{ name, why string }{
|
||||
// The inference surface, whole.
|
||||
{"post_v1_chat_completions", "POST /v1/chat/completions — the flagship"},
|
||||
{"post_v1_responses", "POST /v1/responses"},
|
||||
{"post_v1_embeddings", "POST /v1/embeddings"},
|
||||
{"post_v1_rerank", "POST /v1/rerank"},
|
||||
{"get_v1_models", "GET /v1/models"},
|
||||
{"post_v1_messages_count_tokens", "POST /v1/messages/count_tokens — `token` is a UNIT here; the counting neighbour says so"},
|
||||
{"get_v1_validators_tokenId", "a chain token id, not a bearer token"},
|
||||
|
||||
// The agent loop. Every one of these was refused while `session` was an
|
||||
// unqualified authority noun.
|
||||
{"post_v1_agents_sessions", "POST /v1/agents/sessions — an agent session is a unit of WORK"},
|
||||
{"post_v1_agents_sessions_by_id_message", "the turn itself"},
|
||||
{"post_v1_agents_sessions_by_id_stop", "…and stopping it"},
|
||||
{"patch_v1_agents_sessions_id", "…and steering it"},
|
||||
{"get_v1_agents_sessions_stream", "…and watching it"},
|
||||
{"post_v1_agents_by_ref_run", "POST /v1/agents/{ref}/run"},
|
||||
{"post_v1_agents_targets_id_claim", "claiming a target is not minting its key"},
|
||||
|
||||
// Code, search, git, deploy, exec.
|
||||
{"post_v1_code_ask", "POST /v1/code/ask"},
|
||||
{"post_v1_code_index", "POST /v1/code/index"},
|
||||
{"get_v1_code_search", "GET /v1/code/search"},
|
||||
{"post_v1_search", "POST /v1/search"},
|
||||
{"post_v1_git_repos", "POST /v1/git/repos — repos are not credentials"},
|
||||
{"post_v1_git_repos_name_push", "POST /v1/git/repos/{name}/push"},
|
||||
{"post_v1_deploy_applications_by_name_sync", "POST /v1/deploy/applications/{name}/sync"},
|
||||
{"post_v1_exec", "POST /v1/exec"},
|
||||
|
||||
// Reads of the identity surface survive: knowing who holds a role is not
|
||||
// granting one, and an agent that cannot see the org cannot reason about it.
|
||||
{"GetUser", "GET /v1/o11y/users/{id}"},
|
||||
{"GetRole", "GET /v1/o11y/roles/{id}"},
|
||||
{"GetRolesByUserID", "GET /v1/o11y/users/{id}/roles"},
|
||||
{"GetUserPreference", "GET /v1/o11y/user/preferences/{name} — Slack's 128th tool, and harmless"},
|
||||
{"GetMyUser", "GET /v1/o11y/users/me"},
|
||||
|
||||
// Words that LOOK dangerous and are not. Each names a store entry or a
|
||||
// schema name, not a credential — see keyOfAStore.
|
||||
{"get_v1_o11y_deployments_attribute_keys", "metric label names"},
|
||||
{"delete_v1_pubsub_kv_bucket_key", "DELETE /v1/pubsub/kv/{bucket}/{key}"},
|
||||
{"delete_v1_flags_defs_key", "a feature-flag key"},
|
||||
{"delete_v1_tracker_projects_key", "a tracker project key, e.g. CLOUD-1"},
|
||||
{"patch_v1_tracker_projects_key_issues_num", "…and an issue under it"},
|
||||
{"delete_v1_store_by_storeid_listing_by_key", "the `by_` filler must not become the key's context"},
|
||||
{"delete_v1_cloudflare_kv_namespaces_namespace_values_key", "a KV value"},
|
||||
|
||||
// The whole ai CRUD surface, which zip names `by_owner_by_name`. All 45 of
|
||||
// these were refused while `owner` was an authority noun.
|
||||
{"patch_v1_ai_chats_by_owner_by_name", "PATCH /v1/ai/chats/{owner}/{name}"},
|
||||
{"delete_v1_ai_workflows_by_owner_by_name", "DELETE /v1/ai/workflows/{owner}/{name}"},
|
||||
{"post_v1_ai_deployments_by_owner_by_name_deploy", "POST …/deploy"},
|
||||
|
||||
// Money is a different boundary and this rule does not claim it. Named here
|
||||
// so the scope is a decision on the record rather than an oversight.
|
||||
{"post_v1_research_grants", "a research grant is money, not authority"},
|
||||
}
|
||||
|
||||
func TestRefuse_DangerousOpsAreNotProjected(t *testing.T) {
|
||||
for _, c := range refusals {
|
||||
if !refuse(c.name) {
|
||||
t.Errorf("refuse(%q) = false — this op WOULD reach an agent.\n %s\n words: %v",
|
||||
c.name, c.why, words(c.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefuse_ProductOpsSurvive(t *testing.T) {
|
||||
for _, c := range survivors {
|
||||
if refuse(c.name) {
|
||||
t.Errorf("refuse(%q) = true — the rule ate a tool an agent needs.\n %s\n words: %v",
|
||||
c.name, c.why, words(c.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefuse_TheTwoSetsAgreeWithTheRuleStatement checks the CLAUSE that fired,
|
||||
// not just the verdict — so a name refused for the wrong reason (a bug that
|
||||
// would pass the two tests above) is still caught.
|
||||
func TestRefuse_TheTwoSetsAgreeWithTheRuleStatement(t *testing.T) {
|
||||
for _, c := range refusals {
|
||||
w := words(c.name)
|
||||
if !discloses(w) && !(mutates(w) && authority(w)) {
|
||||
t.Errorf("%q is refused by neither stated clause, so refuse() and %s disagree", c.name, "TheRule")
|
||||
}
|
||||
}
|
||||
// A read of an authority object must fail clause 2 on the VERB, not sneak
|
||||
// past on the object — otherwise GetRole surviving would be an accident.
|
||||
for _, name := range []string{"GetRole", "GetUser", "GetRolesByUserID"} {
|
||||
w := words(name)
|
||||
if !authority(w) {
|
||||
t.Errorf("%q does not read as an authority object; the survival of its READ is then untested", name)
|
||||
}
|
||||
if mutates(w) {
|
||||
t.Errorf("%q reads as a mutation; it is a GET", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefuse_IsVerbBlindAboutSecrets is clause 1's whole point, isolated: the
|
||||
// same object, four verbs, four refusals. GetResetPasswordToken is the op that
|
||||
// proves a mutation-only rule would have been wrong.
|
||||
func TestRefuse_IsVerbBlindAboutSecrets(t *testing.T) {
|
||||
for _, n := range []string{"GetResetPasswordToken", "CreateResetPasswordToken", "VerifyResetPasswordToken", "listTokens"} {
|
||||
if !refuse(n) {
|
||||
t.Errorf("refuse(%q) = false; clause 1 must not depend on the verb", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefuse_ClassifiesNamesItHasNeverSeen is the property a hand-typed roster
|
||||
// of 36 op names cannot have, and the reason the rule is made of nouns.
|
||||
func TestRefuse_ClassifiesNamesItHasNeverSeen(t *testing.T) {
|
||||
// Shapes that do not exist in this fleet today. If someone adds them
|
||||
// tomorrow, they are already classified.
|
||||
for _, n := range []string{
|
||||
"CreateOrganizationApiKey", "post_v1_iam_users_by_id_impersonate",
|
||||
"MintDelegatedCredential", "put_v1_billing_saml_metadata",
|
||||
"RotateSigningKey", "post_v1_notify_channels_by_id_oauth_authorize",
|
||||
} {
|
||||
if !refuse(n) {
|
||||
t.Errorf("refuse(%q) = false — a NEW dangerous op slipped through; words: %v", n, words(n))
|
||||
}
|
||||
}
|
||||
for _, n := range []string{
|
||||
"post_v1_chat_conversations", "get_v1_zen_models", "post_v1_code_review",
|
||||
"get_v1_agents_sessions_by_id_diff", "post_v1_search_reindex",
|
||||
} {
|
||||
if refuse(n) {
|
||||
t.Errorf("refuse(%q) = true — a NEW product op was eaten; words: %v", n, words(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRank_PutsTheProductSurfaceInFrontOfTheConsole is mechanism (b).
|
||||
//
|
||||
// The failure it encodes is the measured one: Slack keeps the first 128 tools,
|
||||
// alphabetical order handed it 128 o11y console ops and zero product tools, and
|
||||
// 'C' < 'a' means no amount of renaming on the product side would have fixed it.
|
||||
func TestRank_PutsTheProductSurfaceInFrontOfTheConsole(t *testing.T) {
|
||||
chat := rank("post_v1_chat_completions")
|
||||
if chat != 0 {
|
||||
t.Errorf("rank(post_v1_chat_completions) = %d, want 0 — chat leads the surface", chat)
|
||||
}
|
||||
console := rank("AgentCheckIn") // sorts FIRST alphabetically, fleet-wide
|
||||
if console != len(productStems) {
|
||||
t.Errorf("rank(AgentCheckIn) = %d — a declared PascalCase id carries no path and cannot match a stem", console)
|
||||
}
|
||||
if chat >= console {
|
||||
t.Fatal("the flagship tool does not outrank the tool that used to be first; the truncation window is unchanged")
|
||||
}
|
||||
// Stem matching is on a '_' boundary, so a longer name under the prefix is
|
||||
// promoted and an unrelated one that merely starts with the same letters is not.
|
||||
if got := rank("get_v1_agents_sessions_stream"); got == len(productStems) {
|
||||
t.Error("a route UNDER a product stem must inherit its rank")
|
||||
}
|
||||
if got := rank("get_v1_agentsomething"); got != len(productStems) {
|
||||
t.Errorf("rank(get_v1_agentsomething) = %d — `v1_agent` must not match across a word boundary", got)
|
||||
}
|
||||
if got := rank("CreateUserFromGit"); got != len(productStems) {
|
||||
t.Errorf("rank(%q) = %d — a name with no HTTP-method word has no path to rank by", "CreateUserFromGit", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRank_TheProductSurfaceFitsATruncatingClient.
|
||||
//
|
||||
// Ordering only helps if the promoted set is SMALLER than the window. Measured
|
||||
// against the fleet's own document, the stems through `v1_exec` promote 126 ops
|
||||
// — so a client that keeps 128 keeps chat, models, the agent loop, code, search,
|
||||
// git, deploy and exec. `v1_projects` and `v1_websearch` are last precisely
|
||||
// because they are the two that spill.
|
||||
func TestRank_TheProductSurfaceFitsATruncatingClient(t *testing.T) {
|
||||
const window = 128
|
||||
head := 0
|
||||
for i, stem := range productStems {
|
||||
if stem == "v1_projects" {
|
||||
head = i
|
||||
}
|
||||
}
|
||||
if head == 0 {
|
||||
t.Fatal("v1_projects left the surface; this test's premise is stale")
|
||||
}
|
||||
if head >= len(productStems) {
|
||||
t.Fatal("v1_projects is last; nothing is being kept inside the window")
|
||||
}
|
||||
// The claim is about counts measured elsewhere (see the doc comment); what
|
||||
// is checkable HERE is that the spill-over stems really are at the end.
|
||||
for i := head; i < len(productStems); i++ {
|
||||
if rank("post_"+productStems[i]) < head {
|
||||
t.Errorf("%q ranks inside the head of the surface", productStems[i])
|
||||
}
|
||||
}
|
||||
if head > window {
|
||||
t.Errorf("the surface has %d stems before the spill, which cannot fit a %d-tool window", head, window)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWords_ReadsBothNamingConventions(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"CreateServiceAccountKey", []string{"create", "service", "account", "key"}},
|
||||
{"CreateLLMScore", []string{"create", "llm", "score"}},
|
||||
{"GetRolesByUserID", []string{"get", "roles", "by", "user", "id"}},
|
||||
{"delete_v1_ai_signin-sessions_by_owner_by_name",
|
||||
[]string{"delete", "v1", "ai", "signin", "sessions", "by", "owner", "by", "name"}},
|
||||
{"post_v1_git_by_org_by_repo_git-upload-pack",
|
||||
[]string{"post", "v1", "git", "by", "org", "by", "repo", "git", "upload", "pack"}},
|
||||
} {
|
||||
got := words(c.in)
|
||||
if strings.Join(got, " ") != strings.Join(c.want, " ") {
|
||||
t.Errorf("words(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefuse_ANamelessToolIsNotProjectable: the door must not carry a descriptor
|
||||
// it cannot route or reason about.
|
||||
func TestRefuse_ANamelessToolIsNotProjectable(t *testing.T) {
|
||||
for _, n := range []string{"", "___", "-"} {
|
||||
if !refuse(n) {
|
||||
t.Errorf("refuse(%q) = false", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -694,7 +694,7 @@ require (
|
||||
github.com/hanzoai/base v1.5.15
|
||||
github.com/hanzoai/licensing v0.1.10
|
||||
github.com/hanzoai/metrics v1.110.2
|
||||
github.com/hanzoai/o11y v1.5.61
|
||||
github.com/hanzoai/o11y v1.5.62
|
||||
github.com/hanzoai/thinking v0.1.1 // indirect
|
||||
github.com/hanzoai/vfs v0.6.6
|
||||
github.com/hanzoai/zen v1.4.11
|
||||
|
||||
@@ -1052,8 +1052,8 @@ github.com/hanzoai/notify v1.7.2 h1:xHaVVcOWLvLMpmtSH128FRgskj4WyfG371K6CI3OC/I=
|
||||
github.com/hanzoai/notify v1.7.2/go.mod h1:GZXKmj6UNtNBz32h0XPugNkYf8R34wlJqWuRstytXZE=
|
||||
github.com/hanzoai/o11y v1.5.58 h1:xp+lMkBwYRdd3TJeUB83Qq8XXnyLBX+nmOQ2hPEwhJU=
|
||||
github.com/hanzoai/o11y v1.5.58/go.mod h1:TrDmdeIBBwijyr5dti7+to4gkHOWxlvB0Lu/oRqS9uw=
|
||||
github.com/hanzoai/o11y v1.5.61 h1:DAsU5w489XjJ76zpuW2IbUEc5PoII7AkDlyoNhkYIQc=
|
||||
github.com/hanzoai/o11y v1.5.61/go.mod h1:ZFtyB6TjUGWJPdO689dvqk2ZaBu+WMqEvMRxP2Jb8tc=
|
||||
github.com/hanzoai/o11y v1.5.62 h1:wKEq7sTTZcBIDdBqIuBokLaM6KtRsaO+I1pFXHlM564=
|
||||
github.com/hanzoai/o11y v1.5.62/go.mod h1:ZFtyB6TjUGWJPdO689dvqk2ZaBu+WMqEvMRxP2Jb8tc=
|
||||
github.com/hanzoai/orm v0.6.21 h1:iqH7h8eUD3uDRKuga5jQJqAC+6oQRxXoApUJFQd+i38=
|
||||
github.com/hanzoai/orm v0.6.21/go.mod h1:mnnchBPY8Z0gEsI0qzBWQnFKj3LerqHOk7wfoOn6MNA=
|
||||
github.com/hanzoai/otel-collector v1.2.0 h1:lBDL5lKotq89JaqchcM+/oxEnjrjazEI2JJfDhotudc=
|
||||
|
||||
@@ -92,6 +92,36 @@ test:
|
||||
echo "$stale"
|
||||
exit 1
|
||||
fi
|
||||
- name: zipdoc-current
|
||||
# The 99 zipdoc_gen.go files are the ONLY path from a typed handler's doc
|
||||
# comment to /v1/openapi.json: Go drops comments at compile time, so cmd/zipdoc
|
||||
# lifting them into a compiled-in file is the whole mechanism. They are
|
||||
# COMMITTED, which is what lets the release image compile the real prose into
|
||||
# every binary WITHOUT running the generator — that RUN cost 355.9s of a
|
||||
# 17-minute build to re-derive files the tree already had, and it is gone.
|
||||
#
|
||||
# Committed means they can go stale, and dropping the build-time pass is only
|
||||
# sound if staleness cannot be MERGED. Nothing here checked that. `make test`
|
||||
# has carried a zipdoc check for a long time, but CI does not run `make test` —
|
||||
# it runs the raw-go steps in this file — so that check has never once executed
|
||||
# in CI. app-contract cannot cover it either: it regenerates these files as a
|
||||
# side effect (mk/plugin.mk: describe → build → generate) and then scopes its
|
||||
# porcelain check to openapi.yaml, openapi/floor.json and plugin/, so a stale
|
||||
# lift is silently repaired in the runner's tree and never reported. Main went
|
||||
# stale straight through that gap — plane.go documents RunOnBehalfIn.Model and
|
||||
# apps/agents/zipdoc_gen.go did not carry a word of it.
|
||||
#
|
||||
# BEFORE app-contract, necessarily: after it the tree is already regenerated and
|
||||
# this would assert nothing. Cheap here — go-vet above has already warmed the
|
||||
# build cache these package loads want (40s warm on an 8-core box).
|
||||
run: |
|
||||
set -e
|
||||
export GOPRIVATE='github.com/hanzoai/*' GOWORK=off
|
||||
# ONE gate definition, called rather than restated — the discipline
|
||||
# app-contract already follows with surface-check. This is the same target a
|
||||
# developer runs, so CI and the Makefile cannot drift apart, which is exactly
|
||||
# how the Makefile's own zipdoc check ended up policing nothing.
|
||||
make zipdoc-check
|
||||
- name: host-is-light
|
||||
# The light host's ONE property is that it links no subsystem: it knows where
|
||||
# each app lives and what path it answers, never what the app does. That is
|
||||
|
||||
+10
-1
@@ -100,7 +100,16 @@ var Apps = []App{
|
||||
// ai's bare "/v1" remainder, whose prepaid balance gate would have made topping
|
||||
// up require the balance the top-up exists to create — the same trap /v1/cart
|
||||
// describes above, on the door that funds it.
|
||||
{Name: "commerce", Prefixes: []string{"/_/commerce", "/v1/billing/alerts", "/v1/billing/credits", "/v1/billing/crypto", "/v1/billing/invoices", "/v1/billing/methods", "/v1/billing/mode", "/v1/billing/payouts", "/v1/billing/plans", "/v1/billing/portal/methods", "/v1/billing/recharge", "/v1/billing/settings", "/v1/billing/subscribe/card", "/v1/billing/subscriptions", "/v1/billing/tier", "/v1/billing/topup", "/v1/billing/webhooks", "/v1/billing/wire", "/v1/cart", "/v1/catalog/entries", "/v1/catalog/models", "/v1/catalog/seed", "/v1/commerce/admin/catalog", "/v1/commerce/catalog", "/v1/commerce/collection", "/v1/commerce/currencies", "/v1/commerce/disclosure", "/v1/commerce/discount", "/v1/commerce/movie", "/v1/commerce/note", "/v1/commerce/product", "/v1/commerce/return", "/v1/commerce/saleschannel", "/v1/commerce/stocklocation", "/v1/commerce/submission", "/v1/commerce/subscriber", "/v1/commerce/tenant", "/v1/commerce/tokentransaction", "/v1/commerce/transfer", "/v1/commerce/variant", "/v1/commerce/wallet", "/v1/commerce/watchlist", "/v1/commerce/webhook", "/v1/payments", "/v1/plans/entries", "/v1/plans/seed", "/v1/store"}},
|
||||
// The customer's own ledger — transactions, credit-balance, accounts (and its
|
||||
// /:id/members child, which the accounts prefix covers) — is named here because
|
||||
// naming it in mount.go is only half an address. mount.go says what the APP will
|
||||
// answer; this row says what the ROUTER may hand it, and a leaf missing here never
|
||||
// reaches commerce at all: it falls to the "/v1" remainder on ai's row and answers
|
||||
// ai's bare 404. That is indistinguishable from an unmounted route from outside,
|
||||
// which is what made this bug survive a correct mount — the binary held the route
|
||||
// and the host never delivered to it. credit-balance is its own entry and not
|
||||
// covered by credits: they are sibling prefixes, not parent and child.
|
||||
{Name: "commerce", Prefixes: []string{"/_/commerce", "/v1/billing/accounts", "/v1/billing/alerts", "/v1/billing/credit-balance", "/v1/billing/credits", "/v1/billing/crypto", "/v1/billing/invoices", "/v1/billing/methods", "/v1/billing/mode", "/v1/billing/payouts", "/v1/billing/plans", "/v1/billing/portal/methods", "/v1/billing/recharge", "/v1/billing/settings", "/v1/billing/subscribe/card", "/v1/billing/subscriptions", "/v1/billing/tier", "/v1/billing/topup", "/v1/billing/transactions", "/v1/billing/webhooks", "/v1/billing/wire", "/v1/cart", "/v1/catalog/entries", "/v1/catalog/models", "/v1/catalog/seed", "/v1/commerce/admin/catalog", "/v1/commerce/catalog", "/v1/commerce/collection", "/v1/commerce/currencies", "/v1/commerce/disclosure", "/v1/commerce/discount", "/v1/commerce/movie", "/v1/commerce/note", "/v1/commerce/product", "/v1/commerce/return", "/v1/commerce/saleschannel", "/v1/commerce/stocklocation", "/v1/commerce/submission", "/v1/commerce/subscriber", "/v1/commerce/tenant", "/v1/commerce/tokentransaction", "/v1/commerce/transfer", "/v1/commerce/variant", "/v1/commerce/wallet", "/v1/commerce/watchlist", "/v1/commerce/webhook", "/v1/payments", "/v1/plans/entries", "/v1/plans/seed", "/v1/store"}},
|
||||
{Name: "licensing", Prefixes: []string{"/v1/licensing"}},
|
||||
{Name: "plan", Prefixes: []string{"/v1/plans"}},
|
||||
{Name: "pricing", Prefixes: []string{"/v1/admin/catalog", "/v1/admin/enablement", "/v1/enablement", "/v1/pricing"}},
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The customer's own ledger reads must be ROUTABLE, not merely mounted.
|
||||
//
|
||||
// This guards a failure that survived a correct fix and looked identical to the
|
||||
// bug it was meant to close. GET /v1/billing/{transactions,credit-balance,
|
||||
// accounts} 404'd because apps/commerce/mount.go never named them; naming them
|
||||
// there, building the image, and verifying the route strings were present in the
|
||||
// shipped binary still left all three answering 404 in production — because a
|
||||
// route reaches an app only when BOTH halves of its address exist. mount.go says
|
||||
// what commerce will answer. This table says what the host may hand it. A leaf
|
||||
// missing here falls to the "/v1" remainder on ai's row and gets ai's bare 404,
|
||||
// which from outside is indistinguishable from a route that was never mounted.
|
||||
//
|
||||
// So the assertion is deliberately about the ROUTER's half. The app's half is
|
||||
// covered by apps/commerce's own route tests, which cannot see this table, and
|
||||
// the byte-level check on a built image cannot see it either — the string was in
|
||||
// the binary the whole time it was returning 404.
|
||||
func TestLedgerReadsAreRoutedToCommerce(t *testing.T) {
|
||||
prefixes := PrefixesFor("commerce")
|
||||
if len(prefixes) == 0 {
|
||||
t.Fatal("commerce claims no prefixes — the app is not routable at all")
|
||||
}
|
||||
|
||||
// accounts/:id/members is covered by the accounts prefix; the others are
|
||||
// leaves in their own right. credit-balance is NOT a child of credits —
|
||||
// they are sibling prefixes, and matching one does not match the other.
|
||||
for _, path := range []string{
|
||||
"/v1/billing/transactions",
|
||||
"/v1/billing/credit-balance",
|
||||
"/v1/billing/accounts",
|
||||
"/v1/billing/accounts/acme/members",
|
||||
} {
|
||||
routed := false
|
||||
for _, p := range prefixes {
|
||||
if path == p || strings.HasPrefix(path, p+"/") {
|
||||
routed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !routed {
|
||||
t.Fatalf("%s is not routed to commerce — the host will hand it to the "+
|
||||
"/v1 remainder and answer 404 no matter what mount.go registers", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// credits and credit-balance are distinct addresses, and a prefix that matched
|
||||
// both would mean one of them was never stated. This is the specific trap in
|
||||
// adding credit-balance next to an existing credits entry: eyeballing the list,
|
||||
// "credits" looks like it already covers it.
|
||||
func TestCreditBalanceIsItsOwnPrefix(t *testing.T) {
|
||||
var credits, balance bool
|
||||
for _, p := range PrefixesFor("commerce") {
|
||||
switch p {
|
||||
case "/v1/billing/credits":
|
||||
credits = true
|
||||
case "/v1/billing/credit-balance":
|
||||
balance = true
|
||||
}
|
||||
}
|
||||
if !credits || !balance {
|
||||
t.Fatalf("both /v1/billing/credits and /v1/billing/credit-balance must be "+
|
||||
"stated (credits=%v credit-balance=%v)", credits, balance)
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -155,7 +155,7 @@ func (m *meteredAI) settle(payer string, req *types.ChatRequest, resp *types.Cha
|
||||
total = resp.PromptTokens + resp.CompletionTokens
|
||||
}
|
||||
if total <= 0 {
|
||||
total = EstTokens(req.Prompt)
|
||||
total = EstTokens(req.Text())
|
||||
}
|
||||
m.record(payer, req.Project, req.Model, metering.Usage{
|
||||
PromptTokens: resp.PromptTokens,
|
||||
@@ -258,7 +258,9 @@ func atMost(req *types.ChatRequest) int {
|
||||
if out <= 0 {
|
||||
out = completionCeiling(req.Model)
|
||||
}
|
||||
return EstTokens(req.Prompt) + out
|
||||
// Text, not Prompt: a tool loop carries its turn in Messages and leaves Prompt
|
||||
// empty, and reserving for an empty prompt is reserving for nothing.
|
||||
return EstTokens(req.Text()) + out
|
||||
}
|
||||
|
||||
// ceilingOf resolves a model's max completion length. Installed at wire-up from
|
||||
|
||||
+214
-1
@@ -1812,6 +1812,11 @@ type RunOnBehalfIn struct {
|
||||
Ref string `json:"ref"`
|
||||
// Input is the user's message, already stripped of the leading @mention.
|
||||
Input string `json:"input"`
|
||||
// Model is the ASKER's own choice, empty when they have not made one. It is a
|
||||
// preference of the person, not a property of the agent, which is why it rides
|
||||
// the turn instead of being written into an agent row: two people in one
|
||||
// workspace can prefer different models of the same assistant.
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
// RunOnBehalfOut is one finished turn.
|
||||
@@ -1825,7 +1830,6 @@ type RunOnBehalfOut struct {
|
||||
RunID string `json:"runId,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
// ---- sandbox ---------------------------------------------------------------
|
||||
|
||||
// LeaseIn asks for a sandbox. ID names one to RESUME: a lease that is still
|
||||
@@ -1917,3 +1921,212 @@ type EndIn struct {
|
||||
ID string `json:"id"`
|
||||
Purge bool `json:"purge,omitempty"`
|
||||
}
|
||||
|
||||
// ---- coding: every seam one autonomous coding run reaches across ------------
|
||||
//
|
||||
// A coding run (`@hanzo code: <repo> <task>`) is the most cross-app act in the
|
||||
// estate: it opens an agents SESSION, resolves and gates an agents TARGET, reads
|
||||
// git's CLONE URL, verifies in git that the pushed branch LANDED, and files the
|
||||
// PR row in tracker. Five apps, and since the fused monolith went away, five
|
||||
// PROCESSES.
|
||||
//
|
||||
// It was written as five direct Go calls. Each of those reads a package global
|
||||
// (`mounted`) that belongs to the OTHER process, so each answered the zero value
|
||||
// in the one deployment that exists: CloneURL returned "" ("git is not
|
||||
// available"), OpenSession returned "not mounted", VerifyRef reported the branch
|
||||
// absent and the run failed CLOSED with no PR. The same shape that killed every
|
||||
// @hanzo chat turn until the on-behalf-of run moved onto this plane.
|
||||
//
|
||||
// So the seams travel. The orchestration itself (apps/coding) is unchanged and
|
||||
// stays ONE library — it always spoke to its collaborators through injected
|
||||
// seams, which is exactly what made this a re-binding rather than a rewrite.
|
||||
const (
|
||||
// AgentsSessionOpen / Event / Close are the live agent session a coding run
|
||||
// streams into: the durable record mission-control renders and the stream the
|
||||
// operator watches. Target, when set, tags the session with the MACHINE the
|
||||
// run was routed to (agents.OpenSessionOn), so a routed run shows up where it
|
||||
// is actually executing.
|
||||
AgentsSessionOpen = "agents_session_open"
|
||||
AgentsSessionEvent = "agents_session_event"
|
||||
AgentsSessionClose = "agents_session_close"
|
||||
|
||||
// AgentsResolveTarget turns a human's `on <machine>` reference — a target id
|
||||
// or the friendly label the CLI registered — into a target id, org-scoped and
|
||||
// fail-closed. The Slack front door parses the word; agents owns the registry
|
||||
// that knows whether the org has such a machine.
|
||||
AgentsResolveTarget = "agents_resolve_target"
|
||||
|
||||
// AgentsTargetGate is the liveness+existence check a routed run passes before
|
||||
// it is enqueued: the target exists in this org, is online, and has a live
|
||||
// runner. Fail-closed — an error here means the run does not dispatch, never
|
||||
// that it quietly runs somewhere else.
|
||||
AgentsTargetGate = "agents_target_gate"
|
||||
|
||||
// AgentsRouteRun enqueues a routed run on the durable engine.
|
||||
//
|
||||
// This one is not merely a store that lives elsewhere: the run is offered to
|
||||
// an IN-MEMORY mailbox that the machine long-polls through agents' own HTTP
|
||||
// surface. Enqueued in any other process, the delivery activity would offer
|
||||
// the run to a mailbox nobody polls and the workflow would burn its whole
|
||||
// budget before failing. The engine that owns the workflow and the mailbox
|
||||
// that hands it out have to be the same process, and that process is agents.
|
||||
AgentsRouteRun = "agents_route_run"
|
||||
|
||||
// GitCloneURL is the canonical clone URL for an org's native repo — the only
|
||||
// thing the sandbox is ever pointed at, so the run cannot reach another
|
||||
// tenant's namespace.
|
||||
GitCloneURL = "git_clone_url"
|
||||
|
||||
// GitVerifyRef reports the tip of a branch by reading the bare repo on git's
|
||||
// own storage. It is the INTEGRITY gate: cloud trusts the ref it can read,
|
||||
// not the remote runner's claim to have pushed one. Found=false is
|
||||
// fail-closed and costs the run its PR.
|
||||
GitVerifyRef = "git_verify_ref"
|
||||
|
||||
// TrackerAgentPR opens the native PR work item for a finished run.
|
||||
TrackerAgentPR = "tracker_agent_pr"
|
||||
)
|
||||
|
||||
// The ORG rides in these arguments rather than on the caller's plane identity,
|
||||
// which is the exception [RunOnBehalfIn] documents and for the same reason: the
|
||||
// tenant is the one that connected the Slack workspace, resolved server-side
|
||||
// from the signature-VERIFIED team_id, and the calling plugin's own identity is
|
||||
// not it. A coding run only ever touches the named org's own session, its own
|
||||
// repo and its own board — it reads nothing across tenants — and the org it
|
||||
// names was never a field the human could set.
|
||||
//
|
||||
// No field below is a map. The encoder refuses one at this boundary (zapenc
|
||||
// layoutOf), and a refused encode is a call that dies before the socket.
|
||||
|
||||
// SessionOpenIn opens the live session a coding run streams into.
|
||||
type SessionOpenIn struct {
|
||||
Org string `json:"org"`
|
||||
Actor string `json:"actor,omitempty"` // the linked Hanzo subject the run is attributed to
|
||||
Agent string `json:"agent"` // agent label, e.g. "hanzo"
|
||||
Title string `json:"title,omitempty"`
|
||||
// Target tags the session with the machine a ROUTED run was sent to. Empty is
|
||||
// the ordinary cloud-sandbox session.
|
||||
Target string `json:"target,omitempty"`
|
||||
}
|
||||
|
||||
// SessionOpened is the new session's id — the branch suffix, the PR body's link,
|
||||
// and the handle every later event carries.
|
||||
type SessionOpened struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
}
|
||||
|
||||
// SessionEventIn appends one event to a live session. Payload is the event's
|
||||
// already-encoded JSON body, carried as bytes because its SHAPE belongs to the
|
||||
// event kind and not to this contract — and because the map it would otherwise
|
||||
// be cannot cross.
|
||||
type SessionEventIn struct {
|
||||
Org string `json:"org"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Kind string `json:"kind"` // "tool-call" | "log" | "status"
|
||||
Actor string `json:"actor,omitempty"`
|
||||
Payload []byte `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// SessionCloseIn transitions a session out of running. Status is "done" or
|
||||
// "error" — the two terminals a coding run has.
|
||||
type SessionCloseIn struct {
|
||||
Org string `json:"org"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// CodingAck is the answer of a coding seam that either worked or returned an
|
||||
// error. It carries a field because a void op answers 204 and a caller cannot
|
||||
// tell 204 from "the op is not there".
|
||||
type CodingAck struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
// TargetRefIn names a machine the way a human did: an id, or the friendly label
|
||||
// the `hanzo code --serve` daemon registered.
|
||||
type TargetRefIn struct {
|
||||
Org string `json:"org"`
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
|
||||
// TargetRef is the resolved machine. An id that resolves to no target IN THIS
|
||||
// ORG is an error, never another tenant's machine and never a silent fallback to
|
||||
// a local run.
|
||||
type TargetRef struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
// TargetGateIn asks whether a run may be dispatched to a machine.
|
||||
type TargetGateIn struct {
|
||||
Org string `json:"org"`
|
||||
TargetID string `json:"targetId"`
|
||||
}
|
||||
|
||||
// RepoRefIn names one of an org's native repos.
|
||||
type RepoRefIn struct {
|
||||
Org string `json:"org"`
|
||||
Repo string `json:"repo"`
|
||||
}
|
||||
|
||||
// RepoCloneURL is the canonical clone URL. Empty means git could not answer,
|
||||
// which the run reads as "git is not available" and refuses to proceed.
|
||||
type RepoCloneURL struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// RefIn names one branch of one repo.
|
||||
type RefIn struct {
|
||||
Org string `json:"org"`
|
||||
Repo string `json:"repo"`
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
|
||||
// RefTip is what git's own storage says about that branch. Found is EXPLICIT
|
||||
// rather than inferred from an empty SHA: "the branch is not there" and "the
|
||||
// read failed" both have to fail the integrity gate, and an absent tip that read
|
||||
// as a present-but-unknown one would file a PR for a branch nobody can fetch.
|
||||
type RefTip struct {
|
||||
SHA string `json:"sha,omitempty"`
|
||||
Found bool `json:"found"`
|
||||
}
|
||||
|
||||
// AgentPRIn opens the native PR work item for a pushed branch.
|
||||
type AgentPRIn struct {
|
||||
Org string `json:"org"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Repo string `json:"repo"`
|
||||
Base string `json:"base,omitempty"`
|
||||
Head string `json:"head"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Assignee string `json:"assignee,omitempty"`
|
||||
}
|
||||
|
||||
// AgentPROut is the created row's stable handle.
|
||||
type AgentPROut struct {
|
||||
Identifier string `json:"identifier"`
|
||||
ProjectKey string `json:"projectKey,omitempty"`
|
||||
Number int `json:"number,omitempty"`
|
||||
}
|
||||
|
||||
// RouteRunIn is the NON-SECRET spec of a run to be executed on a registered
|
||||
// machine. It carries no credential by design: the machine authenticates git
|
||||
// with its own already-held one, so nothing here is a secret and the run can be
|
||||
// durably persisted by the engine without holding a token.
|
||||
type RouteRunIn struct {
|
||||
Org string `json:"org"`
|
||||
TargetID string `json:"targetId"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Repo string `json:"repo"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Base string `json:"base,omitempty"`
|
||||
Branch string `json:"branch"`
|
||||
Prompt string `json:"prompt"`
|
||||
CloneURL string `json:"cloneUrl"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
// Actor + AgentRef are cloud-side attribution for the completion path (the
|
||||
// session close and the PR assignee). They never cross to the machine.
|
||||
Actor string `json:"actor,omitempty"`
|
||||
AgentRef string `json:"agentRef,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/agents"
|
||||
"github.com/hanzoai/cloud/apps/coding"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// Everything a coding run needs from agents, published on the internal plane.
|
||||
//
|
||||
// A coding run opens a live SESSION, streams its progress into it, resolves and
|
||||
// gates the MACHINE an `on <machine>` run was addressed to, and enqueues that run
|
||||
// on the durable engine. All four were direct calls into this package from the
|
||||
// integrations process, and all four read `mounted` — this process's global,
|
||||
// nil over there — so the whole coding path answered "agents: not mounted"
|
||||
// before it ever reached a model. The @hanzo chat turn failed in exactly this
|
||||
// shape until it moved onto the plane; this is the rest of that move.
|
||||
//
|
||||
// The ORG rides in each argument rather than on the caller's plane identity.
|
||||
// That is the exception plane.RunOnBehalfIn documents, and it holds for the same
|
||||
// reason: the tenant is the one that connected the Slack workspace, resolved
|
||||
// server-side from the signature-verified team_id, and the bridge plugin's own
|
||||
// identity is not it. Every op below touches only the named org's own session,
|
||||
// its own machines and its own queue.
|
||||
//
|
||||
// Declared at the app's composition root because the ROUTE op needs both halves
|
||||
// — this app's mailbox and coding's workflow — and coding imports agents, so
|
||||
// agents can never import coding. A main is a leaf; that is what makes it the
|
||||
// one place both can be held. Registering on cloud.Plane() before cloud.Listen
|
||||
// is safe and ordinary: an op registry is a value, ServePlane binds the socket
|
||||
// over it afterwards.
|
||||
func init() {
|
||||
zip.Post[plane.SessionOpenIn, plane.SessionOpened](cloud.Plane(), "/agents/session/open", planeSessionOpen,
|
||||
zip.WithOperationID(plane.AgentsSessionOpen),
|
||||
zip.WithSummary("Open the live session a coding run streams into"))
|
||||
|
||||
zip.Post[plane.SessionEventIn, plane.CodingAck](cloud.Plane(), "/agents/session/event", planeSessionEvent,
|
||||
zip.WithOperationID(plane.AgentsSessionEvent),
|
||||
zip.WithSummary("Append one event to a live session"))
|
||||
|
||||
zip.Post[plane.SessionCloseIn, plane.CodingAck](cloud.Plane(), "/agents/session/close", planeSessionClose,
|
||||
zip.WithOperationID(plane.AgentsSessionClose),
|
||||
zip.WithSummary("Transition a live session to its terminal status"))
|
||||
|
||||
zip.Post[plane.TargetRefIn, plane.TargetRef](cloud.Plane(), "/agents/resolve-target", planeResolveTarget,
|
||||
zip.WithOperationID(plane.AgentsResolveTarget),
|
||||
zip.WithSummary("Resolve an `on <machine>` reference to one of the org's targets"))
|
||||
|
||||
zip.Post[plane.TargetGateIn, plane.CodingAck](cloud.Plane(), "/agents/target-gate", planeTargetGate,
|
||||
zip.WithOperationID(plane.AgentsTargetGate),
|
||||
zip.WithSummary("Whether a run may be dispatched to a machine right now"))
|
||||
|
||||
zip.Post[plane.RouteRunIn, plane.CodingAck](cloud.Plane(), "/agents/route-run", planeRouteRun,
|
||||
zip.WithOperationID(plane.AgentsRouteRun),
|
||||
zip.WithSummary("Enqueue a routed coding run on the durable engine"))
|
||||
}
|
||||
|
||||
// planeSessionOpen opens the session. Target is a VALUE of the request, not a
|
||||
// second op: "no machine" is what an ordinary sandbox run says, and splitting it
|
||||
// in two would be two doors onto one OpenSessionOn.
|
||||
func planeSessionOpen(ctx context.Context, in *plane.SessionOpenIn) (*plane.SessionOpened, error) {
|
||||
id, err := agents.OpenSessionOn(ctx, in.Org, in.Actor, in.Agent, in.Title, in.Target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.SessionOpened{SessionID: id}, nil
|
||||
}
|
||||
|
||||
func planeSessionEvent(ctx context.Context, in *plane.SessionEventIn) (*plane.CodingAck, error) {
|
||||
if err := agents.LogSessionEvent(ctx, in.Org, in.SessionID, in.Kind, in.Actor, in.Payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.CodingAck{OK: true}, nil
|
||||
}
|
||||
|
||||
func planeSessionClose(ctx context.Context, in *plane.SessionCloseIn) (*plane.CodingAck, error) {
|
||||
if err := agents.CloseSession(ctx, in.Org, in.SessionID, in.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.CodingAck{OK: true}, nil
|
||||
}
|
||||
|
||||
// planeResolveTarget answers with the org's machine, or an error. It returns
|
||||
// only the id and the label — never the row — so a caller learns nothing about a
|
||||
// machine it did not already name, and a reference that matches nothing in THIS
|
||||
// org is not found rather than somebody else's target.
|
||||
func planeResolveTarget(ctx context.Context, in *plane.TargetRefIn) (*plane.TargetRef, error) {
|
||||
t, err := agents.ResolveTarget(ctx, in.Org, in.Ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.TargetRef{ID: t.ID, Label: t.Label}, nil
|
||||
}
|
||||
|
||||
// planeTargetGate is fail-closed by construction: the only success is a nil
|
||||
// error from the gate itself, so a machine that is absent, offline or has no
|
||||
// live runner stops the dispatch here rather than downstream.
|
||||
func planeTargetGate(ctx context.Context, in *plane.TargetGateIn) (*plane.CodingAck, error) {
|
||||
if err := agents.TargetDispatchable(ctx, in.Org, in.TargetID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.CodingAck{OK: true}, nil
|
||||
}
|
||||
|
||||
// planeRouteRun puts the run on the durable engine IN THIS PROCESS.
|
||||
//
|
||||
// That placement is the whole point of the op. The delivery activity offers the
|
||||
// run to an in-memory mailbox which the machine long-polls through this app's
|
||||
// HTTP surface; enqueued anywhere else it would be offered to a mailbox nobody
|
||||
// reads, and the workflow would spend its entire budget before failing. One
|
||||
// process holds the engine, the mailbox and the completion — this one.
|
||||
func planeRouteRun(ctx context.Context, in *plane.RouteRunIn) (*plane.CodingAck, error) {
|
||||
if err := coding.Enqueue(ctx, *in, routeLog); err != nil {
|
||||
return nil, fmt.Errorf("agents: could not queue the routed run: %w", err)
|
||||
}
|
||||
return &plane.CodingAck{OK: true}, nil
|
||||
}
|
||||
|
||||
// routeLog carries coding's best-effort failures (a dropped session mirror, a PR
|
||||
// that would not file) into this process's log instead of dropping them.
|
||||
func routeLog(msg string, kv ...any) { luxlog.New("agents").New("subsystem", "coding").Warn(msg, kv...) }
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/git"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// The two facts a coding run needs from git, published on the internal plane.
|
||||
//
|
||||
// Both already existed as exported in-process functions (apps/git/export.go),
|
||||
// and both read git's `mounted` global. That global belongs to THIS process, so
|
||||
// the coding orchestrator — which runs in the integrations process — read nil
|
||||
// through them: CloneURL answered "", which the dispatcher renders as "git is
|
||||
// not available", and VerifyRef answered not-found, which fails a finished run
|
||||
// closed and files no PR. The functions are right; they were simply being called
|
||||
// from the wrong side of a process boundary.
|
||||
//
|
||||
// They are declared HERE, at the app's own composition root, rather than inside
|
||||
// apps/git: what this adds is not a git capability but the DOOR one, and this
|
||||
// file is where the other cross-subsystem wiring already lives (see
|
||||
// plugin/integrations/seams.go). Registration on cloud.Plane() before
|
||||
// cloud.Listen is what ServePlane then binds — an op registry is a value, not a
|
||||
// listener, so declaring it early costs nothing and cannot race the mount.
|
||||
func init() {
|
||||
zip.Post[plane.RepoRefIn, plane.RepoCloneURL](cloud.Plane(), "/git/clone-url", planeCloneURL,
|
||||
zip.WithOperationID(plane.GitCloneURL),
|
||||
zip.WithSummary("The canonical clone URL of an org's native repo"))
|
||||
|
||||
zip.Post[plane.RefIn, plane.RefTip](cloud.Plane(), "/git/verify-ref", planeVerifyRef,
|
||||
zip.WithOperationID(plane.GitVerifyRef),
|
||||
zip.WithSummary("The tip of a branch, read from git's own storage"))
|
||||
}
|
||||
|
||||
// planeCloneURL answers with the org-scoped clone URL, or an EMPTY one when git
|
||||
// cannot say. Empty is a real answer here and not an error: the caller already
|
||||
// treats "no clone URL" as "git is not available" and refuses the run, so
|
||||
// returning an error would only give that same outcome a second spelling.
|
||||
func planeCloneURL(_ context.Context, in *plane.RepoRefIn) (*plane.RepoCloneURL, error) {
|
||||
return &plane.RepoCloneURL{URL: git.CloneURL(in.Org, in.Repo)}, nil
|
||||
}
|
||||
|
||||
// planeVerifyRef reports whether the branch LANDED, and at which commit. Found
|
||||
// is carried explicitly so an absent branch cannot arrive as a present one with
|
||||
// an unknown tip — the integrity gate this feeds is the reason a run's PR exists
|
||||
// at all.
|
||||
func planeVerifyRef(ctx context.Context, in *plane.RefIn) (*plane.RefTip, error) {
|
||||
sha, ok := git.VerifyRef(ctx, in.Org, in.Repo, in.Branch)
|
||||
return &plane.RefTip{SHA: sha, Found: ok}, nil
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/cloud/apps/automations"
|
||||
"github.com/hanzoai/cloud/apps/coding"
|
||||
"github.com/hanzoai/cloud/apps/git"
|
||||
"github.com/hanzoai/cloud/apps/integrations"
|
||||
)
|
||||
|
||||
@@ -20,13 +18,13 @@ import (
|
||||
// wiring lives at a composition root that imports all of them and none of them
|
||||
// imports it. init() runs once at load, before cloud.Listen.
|
||||
func init() {
|
||||
// The coding orchestrator needs git's CloneURL + VerifyRef, but clients/git
|
||||
// imports clients/integrations, so coding -> git would cycle. This root
|
||||
// imports all three and assembles the Dispatcher, injecting it into the Slack
|
||||
// trigger surface. The git functions are plain reads that resolve their state
|
||||
// at call time, so no mount ordering is required. The mirror-failure logger is
|
||||
// nil (those failures are non-fatal and dropped).
|
||||
integrations.SetCodingDispatcher(coding.NewDispatcher(git.CloneURL, git.VerifyRef, nil))
|
||||
// The coding orchestrator is NOT wired here any more. It was, because coding
|
||||
// could not import git (git imports integrations, integrations called coding)
|
||||
// and so its clone-url/verify-ref seams had to arrive from a root that imports
|
||||
// both. Those seams are peer calls now — git is another PROCESS, and the
|
||||
// in-process functions this root passed answered ""/false there, which the
|
||||
// dispatcher reads as "git is not available". With no cycle left to dodge,
|
||||
// integrations builds its own Dispatcher at the trigger (slack_coding.go).
|
||||
|
||||
// Inbound-event seam: a verified provider webhook (or chat channel) fires the
|
||||
// automations engine's Deliver here — the ONE place that imports both, so
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/apps/tracker"
|
||||
"github.com/hanzoai/cloud/plane"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// The agent-PR door, published on the internal plane.
|
||||
//
|
||||
// A finished coding run files its PR work item with tracker.CreateAgentPR, which
|
||||
// begins `if mounted == nil { return "tracker: not mounted" }`. The run happens
|
||||
// in the integrations process, so that is exactly what it returned — every
|
||||
// completed run logged "tracker PR not created" and the branch it had just
|
||||
// pushed and verified reached no board.
|
||||
//
|
||||
// Declared at this app's composition root for the reason plugin/git/seams.go
|
||||
// gives: the capability is tracker's and already exported; what is being added
|
||||
// is the door.
|
||||
func init() {
|
||||
zip.Post[plane.AgentPRIn, plane.AgentPROut](cloud.Plane(), "/tracker/agent-pr", planeAgentPR,
|
||||
zip.WithOperationID(plane.TrackerAgentPR),
|
||||
zip.WithSummary("Open the native PR work item for a coding run's pushed branch"))
|
||||
}
|
||||
|
||||
// planeAgentPR files the row and returns its stable KEY-N handle. A failure is
|
||||
// an ERROR and never an empty handle: the caller records a PR-less run as a
|
||||
// recorded problem, and an empty identifier that arrived as success would show a
|
||||
// Slack card claiming a PR nobody can open.
|
||||
func planeAgentPR(ctx context.Context, in *plane.AgentPRIn) (*plane.AgentPROut, error) {
|
||||
pr, err := tracker.CreateAgentPR(ctx, tracker.AgentPRInput{
|
||||
Org: in.Org, Project: in.Project, Repo: in.Repo, Base: in.Base,
|
||||
Head: in.Head, Title: in.Title, Body: in.Body, Assignee: in.Assignee,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plane.AgentPROut{Identifier: pr.Identifier, ProjectKey: pr.ProjectKey, Number: pr.Number}, nil
|
||||
}
|
||||
@@ -12,7 +12,9 @@ package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/cloud/money"
|
||||
)
|
||||
@@ -127,6 +129,68 @@ type ChatRequest struct {
|
||||
// prompt. Zero means the caller stated no ceiling and the meter reserves the
|
||||
// policy default (CLOUD_AI_MAX_COMPLETION_TOKENS) — never zero.
|
||||
MaxTokens int
|
||||
|
||||
// Messages is the conversation, when there IS one. Prompt is the degenerate
|
||||
// case of it — a single user turn — and stays the only field a caller with
|
||||
// nothing to remember fills in, so every existing call site is unchanged. A
|
||||
// caller that sets Messages owns the whole transcript including its system
|
||||
// turn; Prompt is then only text the prepaid gate prices (see Text), and the
|
||||
// wire carries Messages.
|
||||
//
|
||||
// It exists because a tool call cannot be expressed as a string. The model's
|
||||
// request to run a tool and the result it is handed back are linked by
|
||||
// ToolCall.ID, and that linkage IS the loop — flatten it into prose and the
|
||||
// model is guessing which answer belongs to which question.
|
||||
Messages []ChatMessage
|
||||
// Tools is what the model may call this turn. Empty means it may call
|
||||
// nothing, which is what every caller before tool support asked for and
|
||||
// still gets: no tools field reaches the gateway at all, so the completion is
|
||||
// the one it produced before.
|
||||
Tools []ToolDef
|
||||
}
|
||||
|
||||
// Chat roles — the OpenAI-compatible vocabulary the gateway speaks.
|
||||
const (
|
||||
RoleSystem = "system"
|
||||
RoleUser = "user"
|
||||
RoleAssistant = "assistant"
|
||||
RoleTool = "tool"
|
||||
)
|
||||
|
||||
// ChatMessage is one turn of a conversation. An assistant turn that decided to
|
||||
// call tools carries ToolCalls and usually no Content; the result of one such
|
||||
// call comes back as a RoleTool turn whose ToolCallID names the call it answers.
|
||||
type ChatMessage struct {
|
||||
Role string // RoleSystem | RoleUser | RoleAssistant | RoleTool
|
||||
Content string
|
||||
// ToolCalls are the calls an assistant turn asked for; only ever set on a
|
||||
// RoleAssistant message.
|
||||
ToolCalls []ToolCall
|
||||
// ToolCallID names the ToolCall this RoleTool message answers. A tool result
|
||||
// without it is a result no call claims, and the gateway refuses it.
|
||||
ToolCallID string
|
||||
// Name is the tool a RoleTool message answers for — legibility in a
|
||||
// transcript, optional on the wire.
|
||||
Name string
|
||||
}
|
||||
|
||||
// ToolDef is one tool offered to the model: its name, the prose it decides on,
|
||||
// and the JSON Schema of its arguments. Schema is passed through VERBATIM — it is
|
||||
// the tool plane's own inputSchema, never a re-derivation of it here.
|
||||
type ToolDef struct {
|
||||
Name string
|
||||
Description string
|
||||
Schema json.RawMessage
|
||||
}
|
||||
|
||||
// ToolCall is the model asking for one tool to run. Arguments is the raw JSON
|
||||
// object it emitted, UNPARSED: it belongs to the tool that declared the schema,
|
||||
// which is the only thing that knows how to read it, and a model that emits
|
||||
// malformed JSON is told so rather than having its intent guessed at here.
|
||||
type ToolCall struct {
|
||||
ID string
|
||||
Name string
|
||||
Arguments string
|
||||
}
|
||||
|
||||
// ChatResponse mirrors the AI subsystem's chat-completion response. The token
|
||||
@@ -137,6 +201,38 @@ type ChatResponse struct {
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
// ToolCalls are the calls the model wants run before it can answer. Non-empty
|
||||
// means this is NOT the final answer: the caller runs them, appends each
|
||||
// result as a RoleTool message, and completes again.
|
||||
ToolCalls []ToolCall
|
||||
// FinishReason is why the model stopped — "stop", "tool_calls", "length", …
|
||||
// Empty when the gateway omits it.
|
||||
FinishReason string
|
||||
}
|
||||
|
||||
// Text is everything this request will be charged tokens for, flattened: the
|
||||
// prompt plus every message's content and every tool call it carries. The
|
||||
// prepaid gate estimates against it, so a conversation reserves for what it
|
||||
// actually sends rather than for a Prompt field a tool loop leaves empty.
|
||||
func (r *ChatRequest) Text() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if len(r.Messages) == 0 {
|
||||
return r.Prompt
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(r.Prompt)
|
||||
for _, m := range r.Messages {
|
||||
b.WriteString(m.Content)
|
||||
b.WriteByte('\n')
|
||||
for _, tc := range m.ToolCalls {
|
||||
b.WriteString(tc.Name)
|
||||
b.WriteString(tc.Arguments)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ErrUpstreamBusy marks a TRANSIENT upstream inference failure that a caller may
|
||||
|
||||
Reference in New Issue
Block a user