an endpoint for spawning an agent, so a caller that is not a chat can
A chat agent already has a way to start a worker — the harness gives it one.
Nothing else does. A watcher loop, a plugin, a background job, a cron entry: none
can reach that tool, which is exactly why a commander's loop shells out to
`claude -p` by hand and then invents its own record-keeping for what it started.
So this is the door Officer defines. `POST /sub-agents/spawn` takes a prompt and an
optional cwd and model, and returns either the session key immediately or the
result if asked to wait. Verified both ways against the live server: the
synchronous call came back with the four words it was told to say, and the async
call returned in milliseconds and the agent had written its file within five
seconds.
The budget check comes BEFORE the spawn, and refuses with a 429 naming the window.
The windows belong to the Anthropic account and every `claude` on the machine
spends them, so an endpoint that could start work without asking would burn the
last of one and cut off the turn the owner is sitting in — a failure that lands on
somebody else's conversation, which is the worst place for it.
`confined`, and confined in FACT on the day it was added rather than ahead of
itself — the registry test's comment records that `chat` sat on that list for a day
before it was true. The identity comes from `chatIdentity` on the caller's token
and `member: { osUser, home }` goes to the sidecar, which is the mechanism chat
already runs under. There is no path in the endpoint that chooses a directory, so
there is nothing for a caller to point elsewhere. `member` is passed explicitly
because it is required-but-may-be-undefined on purpose: optional let a call site
forget it, and forgetting means a member's agent running as the owner.
Not a queue, not a supervisor, not a retry. One call starts one agent; a caller
that wants three tries writes three calls, because a retry in here would be a
policy nobody chose applied to work this endpoint cannot read.
Closes todo/sub-agents/an-api-endpoint-for-spawning-an-agent-with-a.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { chatIdentity } from '../chat/chat';
|
||||
import { checkBudget } from './budget';
|
||||
|
||||
/*
|
||||
POST /sub-agents/spawn — an agent asks Officer to run another agent.
|
||||
|
||||
── Why this exists rather than leaning on the harness ──
|
||||
|
||||
A chat agent already has a way to spawn a worker: the harness gives it one. Nothing else does. A watcher
|
||||
loop, a plugin, a background job, a cron entry — none of them can reach that tool, which is exactly why a
|
||||
commander's loop shells out to `claude -p` by hand and then has to invent its own record-keeping for what
|
||||
it started.
|
||||
|
||||
So this is the door Officer defines: the shape, the limits and the vocabulary are the platform's, every
|
||||
caller gets the same one, and none of it changes when a CLI does.
|
||||
|
||||
── The condition that comes before the feature ──
|
||||
|
||||
`checkBudget()` runs first and refuses on a floor. The windows belong to the Anthropic account and are
|
||||
spent by every `claude` on the machine, so an endpoint that can start work without asking would burn the
|
||||
last of a window and kill the turn the owner is sitting in — a failure that lands on somebody else's
|
||||
conversation, which is the worst place for it. The reserve is a guess and the honest shape of a guess is a
|
||||
floor: never spend the last 15%.
|
||||
|
||||
── What it is NOT ──
|
||||
|
||||
Not a queue, not a supervisor, not a retry. One call starts one agent. Whether that agent's work was any
|
||||
good is the caller's problem, and a caller that wants three tries writes three calls — because a retry
|
||||
inside here would be a policy nobody chose, applied to work this endpoint cannot read.
|
||||
*/
|
||||
|
||||
export const subAgentsRouter = createRouter();
|
||||
|
||||
const MAX_PROMPT = 100_000;
|
||||
|
||||
subAgentsRouter.post('/spawn', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.get('body') as { prompt?: string; cwd?: string; model?: string; wait?: boolean };
|
||||
|
||||
const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : '';
|
||||
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
|
||||
if (prompt.length > MAX_PROMPT) throw errors.BAD_REQUEST(`prompt exceeds ${MAX_PROMPT} characters`);
|
||||
|
||||
/*
|
||||
Refused BEFORE anything is spawned, and the reason travels with the refusal.
|
||||
|
||||
A caller that gets "no room" needs to know which window and how close, or its only move is to try again
|
||||
immediately — which is the behaviour this is trying to prevent. 429 rather than 403: this is a "not now",
|
||||
not a "not you".
|
||||
*/
|
||||
const budget = await checkBudget();
|
||||
if (!budget.ok) {
|
||||
return ctx.json(
|
||||
{ error: budget.reason ?? 'no budget to start another agent', limits: budget.limits, problem: budget.problem },
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
The identity is resolved from the TOKEN, never from the body.
|
||||
|
||||
`chatIdentity` is what decides the home directory and the OS user the agent runs as, and it is the same
|
||||
resolution every chat route uses. A caller naming its own email here would be naming whose filesystem to
|
||||
run in.
|
||||
*/
|
||||
const who = await chatIdentity(user);
|
||||
|
||||
// Officer's own handle for the session. The harness's transcript uuid is assigned when the agent first
|
||||
// reports one, which is after this returns — see the note in the response below.
|
||||
const sessionKey = crypto.randomUUID();
|
||||
const spawn = {
|
||||
userId: user.id,
|
||||
email: who.email,
|
||||
username: user.username ?? who.email.split('@')[0] ?? String(user.id),
|
||||
prompt,
|
||||
sessionKey,
|
||||
...(body?.cwd ? { cwd: body.cwd } : {}),
|
||||
...(body?.model ? { model: body.model } : {}),
|
||||
/*
|
||||
Stated explicitly, never omitted. The field is `required-but-may-be-undefined` on purpose — an optional
|
||||
one let a call site forget it, and forgetting it means a member's agent silently running as the owner in
|
||||
the owner's home. `undefined` here says "the owner", and that is only reached when `chatIdentity` says
|
||||
there is no Linux user to run as.
|
||||
*/
|
||||
member: who.osUser ? { osUser: who.osUser, home: who.home } : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
/*
|
||||
Two modes, and the default is the one a loop wants.
|
||||
|
||||
`wait: false` returns as soon as the agent is running, so a watcher can start work and get on with
|
||||
watching. `wait: true` blocks for the answer, which is right for a question with a short answer and
|
||||
wrong for anything that touches a fleet — the request would hold a socket open for the length of the
|
||||
work.
|
||||
*/
|
||||
if (body?.wait === true) {
|
||||
const result = await sidecar.spawnClaude(spawn);
|
||||
return ctx.json({ sessionKey, result });
|
||||
}
|
||||
await sidecar.spawnClaudeStreaming(spawn);
|
||||
/*
|
||||
`sessionKey` and not a transcript id, because the transcript does not exist yet.
|
||||
|
||||
The agent announces its own uuid on its first turn, which is after this responds. A caller that needs
|
||||
the transcript — to open `/chat/<id>`, or to record what it started — reads it from the live session
|
||||
list a moment later. Returning a guess here, or blocking until it arrived, would both be worse than
|
||||
saying which id this is.
|
||||
*/
|
||||
return ctx.json({ sessionKey, running: true });
|
||||
} catch (err) {
|
||||
// The agent sidecar being down is a 503, not a 500: nothing about the request was wrong and retrying it
|
||||
// later is the correct response, which is a distinction a caller can act on.
|
||||
// 503 rather than 500: nothing about the request was wrong, and retrying it later is the correct
|
||||
// response — a distinction the caller can act on.
|
||||
throw errors.createError(503, err instanceof Error ? err.message : 'could not start an agent');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /sub-agents/budget — is there room to start one, without starting one.
|
||||
*
|
||||
* Separate from the spawn so a caller can decide before it commits. A watcher that would rather skip a cycle
|
||||
* than be refused mid-flight asks this first; the spawn checks again regardless, because between the two
|
||||
* calls somebody else may have spent the window.
|
||||
*/
|
||||
subAgentsRouter.get('/budget', async (ctx) => {
|
||||
const budget = await checkBudget();
|
||||
return ctx.json(budget);
|
||||
});
|
||||
Reference in New Issue
Block a user