Compare commits
3 Commits
feat/expor
...
5166fde764
| Author | SHA1 | Date | |
|---|---|---|---|
| 5166fde764 | |||
| b765495eb7 | |||
| 822b3b1edb |
67
.env
67
.env
@@ -1,67 +0,0 @@
|
||||
# ============================================================================
|
||||
# Krow backend — example environment
|
||||
#
|
||||
# Copy to .env and fill in. .env is gitignored and must never be committed.
|
||||
# Every value below is a placeholder or a safe local default: no real password,
|
||||
# API key or token belongs in this file.
|
||||
#
|
||||
# cp .env.example .env
|
||||
# ============================================================================
|
||||
|
||||
# ── Application ─────────────────────────────────────────────────────────────
|
||||
APP_ENV=development
|
||||
LOG_LEVEL=info # debug | info | warn | error
|
||||
|
||||
# ── HTTP server ─────────────────────────────────────────────────────────────
|
||||
HTTP_HOST=127.0.0.1
|
||||
HTTP_PORT=8080
|
||||
HTTP_READ_TIMEOUT=15s
|
||||
# An agent run on the deep tier may legally take 2m0s. A 30s write timeout
|
||||
# aborted the response mid-run and the proxy reported it as 502.
|
||||
HTTP_WRITE_TIMEOUT=2m30s
|
||||
HTTP_IDLE_TIMEOUT=60s
|
||||
HTTP_SHUTDOWN_TIMEOUT=10s
|
||||
|
||||
# ── PostgreSQL ──────────────────────────────────────────────────────────────
|
||||
# The local development database. DATABASE_NAME is mixed-case and hyphenated,
|
||||
# so anything that interpolates it into SQL must quote it: "Krow-force".
|
||||
DATABASE_HOST=127.0.0.1
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_NAME=Krow-force
|
||||
DATABASE_USER=postgres
|
||||
DATABASE_PASSWORD=
|
||||
DATABASE_SCHEMA=public
|
||||
|
||||
# sslmode: disable is fine for a loopback dev database. APP_ENV=production
|
||||
# rejects `disable` at startup — use require or verify-full there.
|
||||
DATABASE_SSLMODE=disable
|
||||
|
||||
# Pool and timeout tuning.
|
||||
DATABASE_MAX_OPEN_CONNS=25
|
||||
DATABASE_MIN_IDLE_CONNS=2
|
||||
DATABASE_CONN_MAX_LIFETIME=30m
|
||||
DATABASE_CONNECT_TIMEOUT=5s
|
||||
DATABASE_STATEMENT_TIMEOUT=10s
|
||||
|
||||
# ── Migrations ──────────────────────────────────────────────────────────────
|
||||
# Consumed by the Makefile, which builds the golang-migrate URL from the
|
||||
# DATABASE_* values above. Keep it pointed at the repository's migrations/.
|
||||
MIGRATIONS_DIR=./migrations
|
||||
|
||||
# ── Seed ────────────────────────────────────────────────────────────────────
|
||||
# The demo fixture, generated from the frontend repository's src/api/seed.js.
|
||||
# The Makefile passes an absolute path; this default suits running from the
|
||||
# repository root.
|
||||
SEED_FIXTURE_PATH=./seed/fixtures/seed.json
|
||||
|
||||
# ── Model gateway ───────────────────────────────────────────────────────────
|
||||
ANTHROPIC_API_KEY=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
|
||||
|
||||
# ── Knowledge layer (retrieval) ─────────────────────────────────────────────
|
||||
# A real semantic embedding model, running locally. No credential, no per-token
|
||||
# cost, and no tenant text leaving this machine. Change either of the first two
|
||||
# and the stored vectors stop being searched — run `make reembed ORG=<slug>`.
|
||||
EMBED_PROVIDER=ollama
|
||||
EMBED_MODEL=nomic-embed-text
|
||||
EMBED_DIMENSIONS=768
|
||||
EMBED_BASE_URL=http://localhost:11434
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,8 @@
|
||||
# Secrets and local configuration
|
||||
# A bare pattern matches at any depth, so this covers infrastructure/.env too.
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# TLS material. The local-db overlay generates a self-signed pair inside the
|
||||
# postgres volume, but nothing stops someone dropping certs here by hand.
|
||||
|
||||
31
CLAUDE.md
31
CLAUDE.md
@@ -136,7 +136,7 @@ The agent loop lives in `src/runtime/loop.py`. It is the highest-risk file in th
|
||||
- Single loop, spec-driven. No per-agent branching.
|
||||
- Decrement budgets **before** dispatch, not after, so a hung tool cannot overrun.
|
||||
- Stream partial assistant text as it arrives; buffer tool calls until complete.
|
||||
- Termination reasons are an enum: `Completed | BudgetExceeded | Deadline | ConfirmationPending | ToolFailure | Refused`. Every run ends with exactly one.
|
||||
- Termination reasons are an enum: `Completed | BudgetExceeded | Deadline | ConfirmationPending | ToolFailure | GatewayFailure | Refused`. Every run ends with exactly one. `GatewayFailure` is the model provider not answering (rate limited, request rejected, credential refused, unreachable) and is deliberately not `ToolFailure`: the two are different operational questions, and until 2026-09-22 the enum could not tell them apart.
|
||||
- Persist a full trajectory per run: every message, tool call, tool result, and budget snapshot. This is what makes debugging and evals possible — it is not optional telemetry.
|
||||
- Delegation is a tool call from the parent's perspective. Subagent runs get their own trajectory, linked by `parent_run_id`.
|
||||
|
||||
@@ -220,21 +220,11 @@ depends on the curated-versus-self-serve decision and is not settled.
|
||||
| Layer | State |
|
||||
|---|---|
|
||||
| Surfaces | `POST /api/v1/agents/{id}/runs` (streams over SSE on `Accept: text/event-stream`), `GET /api/v1/runs/{id}`; the chat panel is the only answering path — the browser simulator is deleted |
|
||||
| Orchestration | spec-driven loop, four bounds claimed before dispatch, six terminations, trajectories in `agent_runs`; delegation per §6 — a subagent is a tool call, runs as the caller, shares the parent budget, capped at depth 2, and writes its own trajectory linked by `parent_run_id` |
|
||||
| Registry | 9 agents + 24 skills as rows; published versions immutable (append-only, trigger-enforced); runs pin the version they started with |
|
||||
| Orchestration | spec-driven loop, four bounds claimed before dispatch, seven terminations, trajectories in `agent_runs`; delegation per §6 — a subagent is a tool call, runs as the caller, shares the parent budget, capped at depth 2, and writes its own trajectory linked by `parent_run_id` |
|
||||
| Registry | 9 agents + 23 skills as rows; published versions immutable (append-only, trigger-enforced); runs pin the version they started with |
|
||||
| Tools | 19, two of which write (`move_application`, `assign_worker`), behind a bound single-use confirmation |
|
||||
| Knowledge | ACL-tagged ingest, hybrid dense + BM25 fused with RRF, pre-filtered |
|
||||
| Gateway | tier → model + effort, token accounting, refusal as an outcome; one wire protocol — `openai`, the chat-completions shape that Groq (the default), Gemini, OpenRouter, Together, vLLM and a local Ollama all serve. The Anthropic path was removed; `MODEL_PROVIDER=anthropic`, a stale `ANTHROPIC_API_KEY` and a leftover `claude-*` id are each refused at startup rather than ignored |
|
||||
|
||||
**Conversational writes are not agent tool calls.** Two skills — `create-position`
|
||||
and `create-employee-role` — collect a record through the chat panel and then
|
||||
write it with the same REST call the manual form uses, as the signed-in user.
|
||||
They are therefore outside I4's confirmation-token mechanism, which governs
|
||||
tools an AGENT invokes on a caller's behalf. The person is making the request
|
||||
themselves, and the flow's review step ("Ready to create this position?") is
|
||||
where they agree to it. Worth knowing rather than worth fixing: if a write is
|
||||
ever moved from the panel into an agent tool, it acquires I4's bound single-use
|
||||
confirmation at that point and not before.
|
||||
| Gateway | tier → model + effort, token accounting, refusal as an outcome |
|
||||
|
||||
**Deviations from this document, all deliberate and all flagged in code:**
|
||||
|
||||
@@ -260,18 +250,7 @@ confirmation at that point and not before.
|
||||
Do not resolve these unilaterally. Flag them and ask.
|
||||
|
||||
- **Who authors agents?** Curated (the team ships specs) vs. self-serve (tenants author their own). Self-serve requires prompt-injection hardening at the authoring boundary, per-tenant cost caps, an approval workflow, and a sandbox — roughly 3× the platform. Current assumption: **curated**, with the registry designed so self-serve is additive later.
|
||||
- **Model hosting.** Self-hosted vs. API vs. mixed by tier. **Still open** —
|
||||
but no longer expensive to change: `MODEL_BASE_URL` + the three `MODEL_*` ids
|
||||
move the whole platform between Groq (the default), Gemini, OpenRouter,
|
||||
Together, vLLM and a local Ollama without a code change, and `make eval-live`
|
||||
runs the suite against whichever is configured. Decide it on the eval
|
||||
evidence, and weigh the I7 case heaviest: a cheaper model that follows the
|
||||
planted injection is a security regression, not a saving.
|
||||
|
||||
The gap that the Anthropic removal opened here is closed: `openai/gpt-oss-120b`
|
||||
on Groq has been through `make eval-live` and passes all three cases including
|
||||
I7 (2026-09-07). The decision itself — self-hosted vs. API vs. mixed by tier —
|
||||
is still open and still not mine to settle.
|
||||
- **Model hosting.** Self-hosted vs. API vs. mixed by tier.
|
||||
- **Confirmation UX.** Inline in-chat vs. an approval queue.
|
||||
|
||||
---
|
||||
|
||||
@@ -733,6 +733,9 @@ func TestMigrationPairsAreComplete(t *testing.T) {
|
||||
// Phase 5: shared rate limit counters, so a limit means the same thing
|
||||
// behind one instance and behind ten.
|
||||
"000015_rate_limits.up.sql",
|
||||
// A seventh termination reason. The CHECK in 000006 was chosen so
|
||||
// this would be a migration rather than an ALTER TYPE; this is it.
|
||||
"000016_gateway_failure_termination.up.sql",
|
||||
}
|
||||
if len(ups) != len(want) {
|
||||
t.Fatalf("%d migrations, want %d — update this list deliberately", len(ups), len(want))
|
||||
|
||||
@@ -1520,3 +1520,84 @@ Find the shifts nobody has taken.
|
||||
t.Errorf("version moved on a restore: %v -> %v", arc["version"], got)
|
||||
}
|
||||
}
|
||||
|
||||
// The reverse of the cycle and unknown-key checks. Those prove an edge is
|
||||
// valid when the PARENT is written; this proves the edge stays valid when the
|
||||
// CHILD is archived. Without it a published parent keeps delegating into
|
||||
// nothing — exactly what krow-workforce-agent did after activity-agent was
|
||||
// archived under it on 2026-09-15.
|
||||
func TestArchivingADelegatedSubagentIsRefused(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
|
||||
agent := func(id, name, status string, version int, subagents ...string) string {
|
||||
var sub string
|
||||
if len(subagents) > 0 {
|
||||
sub = "subagents:\n"
|
||||
for _, s := range subagents {
|
||||
sub += " - " + s + "\n"
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(`---
|
||||
id: %s
|
||||
name: %s
|
||||
description: part of a delegation graph
|
||||
status: %s
|
||||
version: %d
|
||||
pages:
|
||||
- candidates
|
||||
%s---
|
||||
|
||||
## Instructions
|
||||
Delegate.
|
||||
`, id, name, status, version, sub)
|
||||
}
|
||||
|
||||
res := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
||||
"markdown": agent("dep-child", "Child", "published", 1), "visibility": "organization",
|
||||
})
|
||||
if res.code != http.StatusCreated {
|
||||
t.Fatalf("create child: status %d (%v)", res.code, res.body)
|
||||
}
|
||||
childID, _ := res.record(t)["id"].(string)
|
||||
|
||||
res = r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
||||
"markdown": agent("dep-parent", "Parent", "published", 1, "dep-child"), "visibility": "organization",
|
||||
})
|
||||
if res.code != http.StatusCreated {
|
||||
t.Fatalf("create parent: status %d (%v)", res.code, res.body)
|
||||
}
|
||||
parentID, _ := res.record(t)["id"].(string)
|
||||
|
||||
// Both ways of archiving must be refused: the status-only patch the UI
|
||||
// sends, and a markdown save whose frontmatter says archived.
|
||||
for name, patch := range map[string]map[string]any{
|
||||
"status patch": {"status": "archived"},
|
||||
"markdown save": {"markdown": agent("dep-child", "Child", "archived", 2)},
|
||||
} {
|
||||
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+childID, patch)
|
||||
if res.code != http.StatusConflict {
|
||||
t.Fatalf("%s: archiving a delegated-to agent: status %d, want 409 (%v)", name, res.code, res.body)
|
||||
}
|
||||
if body := fmt.Sprint(res.body); !strings.Contains(body, "dep-parent") {
|
||||
t.Errorf("%s: the refusal did not name the dependent: %v", name, res.body)
|
||||
}
|
||||
}
|
||||
|
||||
// Refused, not half-applied.
|
||||
res = r.as(r.admin, "GET", "/api/v1/agent-definitions/"+childID, nil)
|
||||
if st, _ := res.record(t)["status"].(string); st != "published" {
|
||||
t.Fatalf("child status after refused archives = %q, want published", st)
|
||||
}
|
||||
|
||||
// A DRAFT parent does not pin the child. Move the parent to draft and the
|
||||
// archive goes through: an abandoned experiment must not hold a
|
||||
// production agent in place.
|
||||
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+parentID, map[string]any{"status": "draft"})
|
||||
if res.code != http.StatusOK {
|
||||
t.Fatalf("draft the parent: status %d (%v)", res.code, res.body)
|
||||
}
|
||||
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+childID, map[string]any{"status": "archived"})
|
||||
if res.code != http.StatusOK {
|
||||
t.Fatalf("archive with only a draft dependent: status %d, want 200 (%v)", res.code, res.body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ func buildRunResponse(res *runtime.ExecutionResult) runResponse {
|
||||
// reached its budget before finishing" is accurate and means nothing to
|
||||
// somebody who has never heard of a token budget.
|
||||
//
|
||||
// Every one of the six is spelled out. A default that said "something went
|
||||
// Every one of the seven is spelled out. A default that said "something went
|
||||
// wrong" would be the place where a Refused run and a ToolFailure became
|
||||
// indistinguishable to the person best placed to tell us which it was.
|
||||
func terminationMessage(t runtime.Termination) string {
|
||||
@@ -224,6 +224,12 @@ func terminationMessage(t runtime.Termination) string {
|
||||
"Nothing was changed."
|
||||
case runtime.TerminationRefused:
|
||||
return "The agent declined to answer this one."
|
||||
case runtime.TerminationGatewayFailure:
|
||||
// The one termination where "try again" is honest advice: the
|
||||
// dominant cause is a rate limit that clears within a minute, and
|
||||
// nothing about the question itself was the problem.
|
||||
return "The model behind this agent did not answer — usually it is busy. " +
|
||||
"Wait a minute and ask again. Nothing was changed."
|
||||
default:
|
||||
return "The agent did not finish."
|
||||
}
|
||||
|
||||
@@ -22,13 +22,26 @@ const (
|
||||
TerminationConfirmationPending Termination = "ConfirmationPending"
|
||||
TerminationToolFailure Termination = "ToolFailure"
|
||||
TerminationRefused Termination = "Refused"
|
||||
|
||||
// GatewayFailure is the model provider failing to answer at all: rate
|
||||
// limited, rejected the request, refused the credential, or unreachable.
|
||||
// Added 2026-09-22 because until then every one of those was recorded as
|
||||
// ToolFailure, and 131 of 318 production runs read as "a tool is broken"
|
||||
// when no tool had failed — 45 of them were Groq's free-tier rate limit,
|
||||
// which is a capacity decision, not a bug. The two are different questions
|
||||
// to an operator ("what did we break" versus "what are we not paying
|
||||
// for"), and an enum that could not tell them apart hid the answer for
|
||||
// two weeks. Refused and Deadline keep their own reasons; this is the
|
||||
// rest of the gateway's vocabulary.
|
||||
TerminationGatewayFailure Termination = "GatewayFailure"
|
||||
)
|
||||
|
||||
// Valid reports whether t is one of the six.
|
||||
// Valid reports whether t is one of the seven.
|
||||
func (t Termination) Valid() bool {
|
||||
switch t {
|
||||
case TerminationCompleted, TerminationBudgetExceeded, TerminationDeadline,
|
||||
TerminationConfirmationPending, TerminationToolFailure, TerminationRefused:
|
||||
TerminationConfirmationPending, TerminationToolFailure, TerminationRefused,
|
||||
TerminationGatewayFailure:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -241,12 +241,16 @@ func (m *ModelExecutor) delegate(
|
||||
rec.AddChildren(collected.Runs)
|
||||
|
||||
if res == nil {
|
||||
msg := "the subagent returned nothing"
|
||||
// The parent sees a delegation as a tool call, but the REASON it
|
||||
// failed is still worth carrying: a subagent the provider rate limited
|
||||
// should read as GatewayFailure in the parent's trajectory too, or the
|
||||
// parent's operator goes looking for a tool that never broke.
|
||||
msg, term := "the subagent returned nothing", TerminationToolFailure
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
msg, term = err.Error(), terminationFor(err)
|
||||
}
|
||||
return delegationAnswer{
|
||||
Agent: sub.ID, Termination: string(TerminationToolFailure), Error: msg,
|
||||
Agent: sub.ID, Termination: string(term), Error: msg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -555,6 +555,12 @@ func (m *ModelExecutor) runTools(
|
||||
// a Refused run is one that must not be retried. Flattening them into a single
|
||||
// failure reason would make every one of those distinctions unanswerable from
|
||||
// the trajectory.
|
||||
//
|
||||
// Any other gateway error is GatewayFailure, not ToolFailure. The provider
|
||||
// being rate limited, rejecting the request or refusing the key is not a tool
|
||||
// failing, and calling it one sent two weeks of operators looking for a broken
|
||||
// tool that did not exist. A non-gateway error — a tool the model invented, a
|
||||
// result that could not be encoded — is still the tool layer's.
|
||||
func terminationFor(err error) Termination {
|
||||
var gwErr *gateway.Error
|
||||
if !errors.As(err, &gwErr) {
|
||||
@@ -566,7 +572,7 @@ func terminationFor(err error) Termination {
|
||||
case gateway.CodeTimeout:
|
||||
return TerminationDeadline
|
||||
default:
|
||||
return TerminationToolFailure
|
||||
return TerminationGatewayFailure
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,6 +663,8 @@ func terminationMessage(t Termination) string {
|
||||
return "the run is waiting on a confirmation"
|
||||
case TerminationToolFailure:
|
||||
return "the run failed"
|
||||
case TerminationGatewayFailure:
|
||||
return "the model provider did not answer"
|
||||
default:
|
||||
return string(t)
|
||||
}
|
||||
|
||||
@@ -277,6 +277,7 @@ func TestTerminationValidRejectsInvented(t *testing.T) {
|
||||
for _, ok := range []Termination{
|
||||
TerminationCompleted, TerminationBudgetExceeded, TerminationDeadline,
|
||||
TerminationConfirmationPending, TerminationToolFailure, TerminationRefused,
|
||||
TerminationGatewayFailure,
|
||||
} {
|
||||
if !ok.Valid() {
|
||||
t.Errorf("%q should be a valid termination", ok)
|
||||
@@ -1075,3 +1076,31 @@ func TestTheTrajectoryRecordsWhichChunksGroundedTheAnswer(t *testing.T) {
|
||||
t.Error("nothing in the trajectory says a retrieval happened")
|
||||
}
|
||||
}
|
||||
|
||||
// The whole reason GatewayFailure exists: a provider that will not answer is
|
||||
// not a tool that broke, and for two weeks the trajectory said it was.
|
||||
func TestTerminationForSeparatesGatewayFromTool(t *testing.T) {
|
||||
gw := func(code string) error { return &gateway.Error{Code: code, Message: "x"} }
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want Termination
|
||||
}{
|
||||
{"rate limited is the gateway's", gw(gateway.CodeRateLimited), TerminationGatewayFailure},
|
||||
{"invalid request is the gateway's", gw(gateway.CodeInvalidRequest), TerminationGatewayFailure},
|
||||
{"bad credential is the gateway's", gw(gateway.CodeUnauthorized), TerminationGatewayFailure},
|
||||
{"upstream is the gateway's", gw(gateway.CodeUpstream), TerminationGatewayFailure},
|
||||
{"not configured is the gateway's", gw(gateway.CodeNotConfigured), TerminationGatewayFailure},
|
||||
{"refused keeps its own reason", gw(gateway.CodeRefused), TerminationRefused},
|
||||
{"timeout keeps its own reason", gw(gateway.CodeTimeout), TerminationDeadline},
|
||||
{"a non-gateway error is still the tool layer's", errors.New("tool exploded"), TerminationToolFailure},
|
||||
{"a wrapped gateway error is still found", fmt.Errorf("delegating: %w", gw(gateway.CodeRateLimited)), TerminationGatewayFailure},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := terminationFor(tc.err); got != tc.want {
|
||||
t.Errorf("terminationFor(%v) = %s, want %s", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ func (r *Recorder) SetModel(model string) {
|
||||
|
||||
// Finish closes the trajectory with its termination reason and returns it.
|
||||
//
|
||||
// A reason that is not one of the six is recorded as ToolFailure rather than
|
||||
// A reason that is not one of the seven is recorded as ToolFailure rather than
|
||||
// stored as-is: an unrecognised termination is a bug in the loop, and writing
|
||||
// it verbatim would let that bug propagate into every eval and dashboard that
|
||||
// groups by this column.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -352,6 +353,11 @@ func (s *DefinitionsService) UpdateAgent(ctx context.Context, ident authctx.Iden
|
||||
if err := s.refuseSubagentCycle(ctx, ident, agent.ID, agent.Subagents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if agent.Status == "archived" {
|
||||
if err := s.refuseArchivingDependency(ctx, ident, agent.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.refusePublishedRewrite(ctx, ident, repo.KindAgent,
|
||||
agent.ID, markdown, agent.Status, agent.Version); err != nil {
|
||||
return nil, err
|
||||
@@ -361,6 +367,12 @@ func (s *DefinitionsService) UpdateAgent(ctx context.Context, ident authctx.Iden
|
||||
if !isStr || (status != "draft" && status != "published" && status != "archived") {
|
||||
return nil, domain.Validation("status must be one of: draft, published, archived", map[string]string{"status": "invalid"})
|
||||
}
|
||||
if status == "archived" {
|
||||
did, _ := existing["definition_id"].(string)
|
||||
if err := s.refuseArchivingDependency(ctx, ident, did); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
input.Status = &status
|
||||
}
|
||||
|
||||
@@ -671,6 +683,70 @@ func (s *DefinitionsService) refuseSubagentCycle(ctx context.Context,
|
||||
return nil
|
||||
}
|
||||
|
||||
// refuseArchivingDependency fails an archive while a published agent in the
|
||||
// organization still delegates to the definition.
|
||||
//
|
||||
// §3 says an unknown subagent key fails at publish, not at run time, and
|
||||
// refuseSubagentCycle is half of that. This is the other half. Publish
|
||||
// validation proves the edge exists when the PARENT is written; nothing
|
||||
// re-checked it when the CHILD was later archived, so a spec could pass
|
||||
// validation on Monday and be delegating into nothing by Friday. That is what
|
||||
// happened to krow-workforce-agent on 2026-09-15: activity-agent was archived
|
||||
// under it, and every run since logged runtime.unknown_subagent and answered
|
||||
// activity questions without its activity capability — quietly, because the
|
||||
// parent still Completed.
|
||||
//
|
||||
// Only published parents count. A draft that names this agent is the author's
|
||||
// problem at their next publish, where rejectUnknownTools-style validation
|
||||
// will tell them; refusing an archive on the strength of a draft would let an
|
||||
// abandoned experiment pin a production agent in place forever.
|
||||
//
|
||||
// Unlike refuseSubagentCycle this FAILS CLOSED when the graph cannot be read.
|
||||
// The cycle check can afford to fail open because the runtime depth cap holds
|
||||
// regardless; the only backstop here is a parent that keeps answering with a
|
||||
// capability missing, which is the failure this exists to prevent.
|
||||
func (s *DefinitionsService) refuseArchivingDependency(ctx context.Context,
|
||||
ident authctx.Identity, definitionID string) error {
|
||||
|
||||
if definitionID == "" {
|
||||
return nil
|
||||
}
|
||||
rows, _, err := s.repo.ListAgents(ctx, ident, repo.DefinitionListParams{
|
||||
Visibility: "organization", Limit: 500,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Internal(fmt.Errorf("could not check whether any published agent delegates to %q: %w", definitionID, err))
|
||||
}
|
||||
|
||||
var dependents []string
|
||||
for _, rec := range rows {
|
||||
id, _ := rec["definition_id"].(string)
|
||||
markdown, _ := rec["markdown"].(string)
|
||||
status, _ := rec["status"].(string)
|
||||
if id == "" || id == definitionID || markdown == "" || status != "published" {
|
||||
continue
|
||||
}
|
||||
parsed, err := definition.ParseAgent(markdown, definition.Options{})
|
||||
if err != nil || parsed == nil {
|
||||
continue
|
||||
}
|
||||
for _, sub := range parsed.Subagents {
|
||||
if sub == definitionID {
|
||||
dependents = append(dependents, id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dependents) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(dependents)
|
||||
return domain.Conflict(fmt.Sprintf(
|
||||
"%s cannot be archived while a published agent delegates to it: %s. "+
|
||||
"Publish a version of each without it in `subagents` first, or archive them too.",
|
||||
definitionID, strings.Join(dependents, ", ")))
|
||||
}
|
||||
|
||||
// refusePublishedRewrite fails a publish that would change a version already
|
||||
// published, BEFORE anything is written.
|
||||
//
|
||||
|
||||
10
migrations/000016_gateway_failure_termination.down.sql
Normal file
10
migrations/000016_gateway_failure_termination.down.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Reverting narrows the vocabulary, so rows that used the seventh value are
|
||||
-- folded back into ToolFailure FIRST — the classification they would have had
|
||||
-- before 000016 — or the narrower CHECK cannot be re-added at all. This loses
|
||||
-- the distinction the up migration introduced, which is what reverting means.
|
||||
UPDATE agent_runs SET termination = 'ToolFailure' WHERE termination = 'GatewayFailure';
|
||||
ALTER TABLE agent_runs DROP CONSTRAINT agent_runs_termination_check;
|
||||
ALTER TABLE agent_runs ADD CONSTRAINT agent_runs_termination_check CHECK (termination IN (
|
||||
'Completed', 'BudgetExceeded', 'Deadline',
|
||||
'ConfirmationPending', 'ToolFailure', 'Refused'
|
||||
));
|
||||
22
migrations/000016_gateway_failure_termination.up.sql
Normal file
22
migrations/000016_gateway_failure_termination.up.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- A seventh termination reason: GatewayFailure.
|
||||
--
|
||||
-- 000006 chose a CHECK over an enum type so that "a new termination reason
|
||||
-- should be a migration, but not one that requires ALTER TYPE". This is that
|
||||
-- migration.
|
||||
--
|
||||
-- Until now the model provider failing — rate limited, request rejected, key
|
||||
-- refused, unreachable — was recorded as ToolFailure, because the enum had no
|
||||
-- other place for it. On 2026-09-22 that was 131 of 318 production runs, of
|
||||
-- which not one was a tool failing. The distinction is the difference between
|
||||
-- "what did we break" and "what are we not paying for", and the column that
|
||||
-- every dashboard and eval groups by could not make it.
|
||||
--
|
||||
-- Existing rows are left as they are. Rewriting history from the trajectory
|
||||
-- text would be guesswork against a message format that has changed twice;
|
||||
-- the trajectory entries still carry the gateway.* error code for anyone who
|
||||
-- needs to reclassify the past.
|
||||
ALTER TABLE agent_runs DROP CONSTRAINT agent_runs_termination_check;
|
||||
ALTER TABLE agent_runs ADD CONSTRAINT agent_runs_termination_check CHECK (termination IN (
|
||||
'Completed', 'BudgetExceeded', 'Deadline',
|
||||
'ConfirmationPending', 'ToolFailure', 'Refused', 'GatewayFailure'
|
||||
));
|
||||
Reference in New Issue
Block a user