Skip to content

Commit 708c946

Browse files
kevin-dpclaude
andauthored
feat(agents): add /goal slash command with token-budget enforcement (#4552)
## Summary Adds a `/goal` slash command to Horton sessions. The user sets an objective with an optional token cap; the agent works autonomously toward it and stops when `mark_goal_complete` is called or when the run exceeds the budget. Budget enforcement is **mid-run** (via an `onStepEnd` hook on the outbound bridge), not at run boundaries — so a goal with a small budget actually halts the agent within a step, rather than letting it finish whatever giant tool-loop is in flight. ```text /goal set "ship feature X" --tokens 50k # default 50k tokens /goal set "explore" --unlimited # opt out of the cap /goal show # current state /goal complete # mark done manually /goal clear # remove the goal ``` ## Behaviour - **One goal per session**, persisted as a `kind: 'goal'` entry on the `manifests` collection — resumes across desktop restarts via the existing Electric sync, no schema migration. - **Mid-run token enforcement**: an `onStepEnd` hook on the outbound bridge surfaces per-step input/output tokens; Horton accumulates them and aborts `ctx.agent.run()` via an `AbortController` once `tokensUsed >= tokenBudget`. The cap covers the sum of input + output across all steps since the goal was set. - **Live progress**: the goal banner ticks up after each step. The manifest update is written via `writeEvent` directly (not the wake-session's staged manifest transaction, which only commits at end-of-wake — too late for a long-running run). - **`mark_goal_complete` tool**: registered on Horton's tool list; flips status to `complete`. The chat reply renders via the new `ctx.replyText` helper, which synthesizes a complete `runs + texts + textDeltas` sequence. - **State-changing `/goal` commands interrupt the active run** — `/goal complete`, `/goal clear`, and `/goal set` typed while a run is in flight signal SIGINT alongside sending the message, so the prior run aborts instead of finishing its old work first. `/goal show` is read-only and never interrupts. - **Budget-limited stop message**: when the cap is hit mid-run, the agent posts a synthetic reply explaining what happened (with a suggested larger budget) instead of just falling silent. ## Plumbing - `entity-schema.ts` — new `ManifestGoalEntryValue` (objective, status, tokenBudget, tokensUsed, tokensAtCreation, createdAt, updatedAt) added to the manifest discriminated union. - `goal-api.ts` (new) — `setGoal` / `clearGoal` / `getGoal` / `markGoalComplete` / `markGoalBudgetLimited` / `updateGoalUsage` / `refreshGoalUsage`. `updateGoalUsage` writes the manifest update directly through `writeEvent` for live UI; `refreshGoalUsage` never decreases `tokensUsed` so a stale collection sum can't clobber an authoritative in-memory value. - `goal-command.ts` (new) — `/goal` parser (`--tokens N|50k|1.2m|unlimited`, `--unlimited` flag, subcommand aliases `done`/`status`) and dispatcher. - `tools/goal-tools.ts` (new) — `createMarkGoalCompleteTool` exposes the completion signal to the LLM. - `outbound-bridge.ts` — new optional `OutboundBridgeHooks.onStepEnd` callback, threaded through `pi-adapter` and the `AgentConfig` passed to `useAgent`. - `context-factory.ts` — `AgentHandle.run` now accepts an optional `abortSignal` and combines it with the runtime's `runSignal`. New `ctx.replyText(text)` writes a complete runs+texts+textDeltas sequence so synthetic replies render in the chat. New goal-related methods exposed on `HandlerContext`. - `horton.ts` — `tryHandleSlashCommand` intercepts `/goal *` before the LLM; `/goal set` enqueues a one-shot kickoff so the agent starts immediately; `assistantHandler` wires the budget-enforcing `onStepEnd`, aborts on overflow, and posts the explanation reply. - `agents-server-ui` — new `GoalBanner` component above the timeline (objective + budget bar + status badge). `MessageInput` aborts the active run when a state-changing `/goal` command is submitted. `EntityTimeline` / `EntityContextDrawer` handle the new `goal` manifest kind. ## Stacking Branched off `kevin/agent-token-usage` (#4502) since this depends on the persisted per-step `input_tokens` / `output_tokens` columns from that PR. Base will retarget to `main` when #4502 merges. ## Test plan - [x] `npx tsc --noEmit` clean in `agents-runtime`, `agents`, and `agents-server-ui` - [x] 21/21 unit tests pass in `packages/agents-runtime/test/goal-command.test.ts` (parser + dispatcher, including `--tokens` formats, `unlimited`, error paths, and all subcommands) - [x] Manual: `/goal set "..." --tokens 100k` — banner appears, agent kicks off, tokens tick up live during the run, goal completes when model calls `mark_goal_complete` - [x] Manual: `/goal set "..." --tokens 5k` on a large task — agent halts mid-run with `budget_limited` status and the explanation reply - [x] Manual: `/goal complete` typed while a run is active — prior run aborts via SIGINT, goal flips to complete - [x] Manual: `/goal set "B"` typed while goal A is running — prior run aborts, goal A replaced, agent kicks off on B - [x] Manual: `/goal clear` removes the banner - [x] Manual: `/goal show` reports current state (no abort) - [x] Manual: persistence across desktop restart — goal entry resumes correctly - [ ] Manual: a no-goal regression check (normal chat behaves unchanged) ## Followups (deferred) - The `/goal set` kickoff message (`Start working toward the active goal now...`) is currently visible in the chat as a self-sent inbox message. Could be filtered from the LLM's view or styled differently. - Dollar-cost budgets (instead of raw tokens) would be more intuitive but require provider-specific pricing tables. - Goose-style "verify the goal is met before stopping" nudge is not implemented; the model just calls `mark_goal_complete` when it decides it's done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c48c1a8 commit 708c946

28 files changed

Lines changed: 2070 additions & 50 deletions

.changeset/agent-goal-tracking.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
---
2+
'@electric-ax/agents-server-ui': patch
3+
'@electric-ax/agents-runtime': patch
4+
'@electric-ax/agents': patch
5+
---
6+
7+
Add `/goal` slash command to Horton sessions. Lets the user set an
8+
objective with an optional token budget; the agent works autonomously
9+
toward the goal and stops when it calls `mark_goal_complete` or when
10+
the run exceeds the budget.
11+
12+
```text
13+
/goal set "ship feature X" --tokens 50k # default 50k tokens
14+
/goal set "explore" --unlimited # opt out of the cap
15+
/goal show # current state
16+
/goal complete # mark done manually
17+
/goal clear # remove the goal
18+
```
19+
20+
## Behaviour
21+
22+
- **One goal per session**, persisted as a `kind: 'goal'` entry on the
23+
`manifests` collection — resumes automatically across desktop
24+
restarts.
25+
- **Mid-run token enforcement**: an `onStepEnd` hook on the outbound
26+
bridge surfaces per-step token counts; Horton accumulates them and
27+
aborts the active `ctx.agent.run()` via an `AbortController` once
28+
`tokensUsed >= tokenBudget`. The cap counts **new input (fresh +
29+
cache-write tokens) + output** per step — prompt-cache reads (which
30+
re-count the whole conversation on every warm step) are excluded, so
31+
the budget tracks new work rather than context size.
32+
- **Live progress**: the goal banner ticks up after each step. The
33+
manifest update is written via `writeEvent` directly (not the
34+
wake-session's staged manifest transaction, which only commits at
35+
end-of-wake — too late for a long-running run).
36+
- **`mark_goal_complete` tool**: registered on Horton's tool list.
37+
Flips status to `complete`, surfaces in the chat as an ordinary
38+
agent reply via the new `ctx.replyText` helper.
39+
- **State-changing `/goal` commands interrupt the active run**
40+
typing `/goal complete`, `/goal clear`, or `/goal set` while a run
41+
is in flight signals SIGINT alongside sending the message, so the
42+
prior run aborts instead of finishing the old work first. `/goal
43+
show` is read-only and does not interrupt.
44+
- **Budget-limited stop message**: when the cap is hit mid-run, the
45+
agent posts a synthetic reply explaining what happened and
46+
suggesting a larger budget to resume.
47+
48+
## Plumbing
49+
50+
- `entity-schema.ts` — new `ManifestGoalEntryValue` (objective,
51+
status, tokenBudget, tokensUsed, createdAt, updatedAt) added to the
52+
manifest discriminated union.
53+
- `goal-api.ts` (new) — `setGoal` / `clearGoal` / `getGoal` /
54+
`markGoalComplete` / `updateGoalUsage`. All goal mutations share a
55+
single ordered write channel (direct `writeEvent` upserts, live for
56+
the UI) plus an in-wake read-your-writes cache, so a mutation firing
57+
mid-run can never snapshot — and replay — a stale `tokensUsed` over
58+
a fresher one. `updateGoalUsage` additionally never decreases the
59+
counter.
60+
- `goal-command.ts` (new) — `/goal` parser (`--tokens N|50k|1.2m|
61+
unlimited`, `--unlimited` flag, subcommand aliases `done`/`status`)
62+
and dispatcher.
63+
- `tools/goal-tools.ts` (new) — `createMarkGoalCompleteTool` exposes
64+
the completion signal to the LLM.
65+
- `outbound-bridge.ts` — new optional `OutboundBridgeHooks.onStepEnd`
66+
callback, threaded through `pi-adapter` and the `AgentConfig` passed
67+
to `useAgent`.
68+
- `context-factory.ts``AgentHandle.run` now accepts an optional
69+
`abortSignal` and combines it with the runtime's `runSignal`. New
70+
`ctx.replyText(text)` writes a complete runs + texts + textDeltas
71+
sequence so synthetic replies render in the chat. New goal-related
72+
methods exposed on `HandlerContext`.
73+
- `horton.ts``tryHandleSlashCommand` intercepts `/goal *` before
74+
the LLM; `/goal set` enqueues a one-shot kickoff so the agent starts
75+
immediately; `assistantHandler` wires the budget-enforcing
76+
`onStepEnd`, aborts on overflow, and posts the explanation reply.
77+
- `agents-server-ui` — new `GoalBanner` component above the timeline
78+
(objective + budget bar + status badge). `MessageInput` aborts the
79+
active run when a state-changing `/goal` command is submitted.
80+
`EntityTimeline` / `EntityContextDrawer` handle the new `goal`
81+
manifest kind.

packages/agents-runtime/src/client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ export {
4040
createSlashCommandTokenRegex,
4141
SLASH_COMMAND_TRIGGER_REGEX,
4242
} from './composer-input'
43+
// The /goal text grammar — pure parsing, shared with the UI so composer
44+
// behavior (e.g. which subcommands interrupt a running agent) can't
45+
// drift from the runtime dispatcher.
46+
export { isGoalCommandText, parseGoalCommand } from './goal-command'
47+
export { formatTokenCount } from './token-budget'
48+
export type { GoalCommand } from './goal-command'
4349

4450
export type {
4551
EntityStreamDB,
@@ -58,8 +64,10 @@ export type {
5864
AttachmentStatus,
5965
AttachmentSubject,
6066
AttachmentSubjectType,
67+
GoalStatus,
6168
Manifest,
6269
ManifestAttachmentEntry,
70+
ManifestGoalEntry,
6371
} from './entity-schema'
6472
export type {
6573
AttachmentCreateInput,

packages/agents-runtime/src/context-factory.ts

Lines changed: 110 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ import { queryOnce } from '@durable-streams/state/db'
22
import { assembleContext } from './context-assembly'
33
import { createContextEntriesApi } from './context-entries'
44
import { entityStateSchema } from './entity-schema'
5+
import { createGoalApi } from './goal-api'
56
import { formatPointerOrderToken } from './event-pointer'
6-
import { createOutboundBridge, loadOutboundIdSeed } from './outbound-bridge'
7+
import {
8+
allocateRunKey,
9+
createOutboundBridge,
10+
loadOutboundIdSeed,
11+
} from './outbound-bridge'
712
import { createPiAgentAdapter } from './pi-adapter'
813
import {
914
timelineMessages as runtimeTimelineMessages,
@@ -249,6 +254,29 @@ function getTriggerMessageText(
249254
})
250255
}
251256

257+
function combineAbortSignals(a: AbortSignal, b: AbortSignal): AbortSignal {
258+
// Prefer the platform helper when available (Node 20+, modern browsers).
259+
const any = (
260+
AbortSignal as unknown as {
261+
any?: (sigs: Array<AbortSignal>) => AbortSignal
262+
}
263+
).any
264+
if (typeof any === `function`) return any.call(AbortSignal, [a, b])
265+
const controller = new AbortController()
266+
const linkTo = (source: AbortSignal): void => {
267+
if (source.aborted) {
268+
controller.abort(source.reason)
269+
return
270+
}
271+
source.addEventListener(`abort`, () => controller.abort(source.reason), {
272+
once: true,
273+
})
274+
}
275+
linkTo(a)
276+
linkTo(b)
277+
return controller.signal
278+
}
279+
252280
function toHandlerWake(wakeEvent: WakeEvent): HandlerWake {
253281
if (wakeEvent.type === `inbox`) {
254282
return {
@@ -450,23 +478,16 @@ export function createHandlerContext<TState extends StateProxy = StateProxy>(
450478
let useContextConfig: UseContextConfig | null = null
451479
let useContextHash = ``
452480
let useContextRegistrations = 0
453-
// Lazy-loaded run-id counter used by ctx.recordRun(). Initialized
454-
// from the runs already present in the entity's StreamDB so keys
455-
// remain monotonic across handler invocations.
456-
let recordRunCounter: number | null = null
481+
// Run-id allocation for ctx.recordRun() / ctx.replyText(). Delegates
482+
// to the outbound bridge's shared id-seed cache so synthetic runs
483+
// can't collide with `run-N` keys the bridge allocated for events
484+
// that haven't round-tripped into the local collection yet. The local
485+
// floor keeps sequential allocations monotonic within this handler
486+
// even when the collection lags (or has no stable id, as in tests).
487+
let localRunFloor = 0
457488
const nextRunKey = (): string => {
458-
if (recordRunCounter == null) {
459-
let max = 0
460-
const rows = config.db.collections.runs.toArray as Array<{ key: string }>
461-
for (const row of rows) {
462-
const m = row.key.match(/^run-(\d+)/)
463-
if (!m) continue
464-
max = Math.max(max, parseInt(m[1]!, 10) + 1)
465-
}
466-
recordRunCounter = max
467-
}
468-
const key = `run-${recordRunCounter}`
469-
recordRunCounter += 1
489+
const key = allocateRunKey(config.db, localRunFloor)
490+
localRunFloor = parseInt(key.slice(`run-`.length), 10) + 1
470491
return key
471492
}
472493

@@ -476,6 +497,12 @@ export function createHandlerContext<TState extends StateProxy = StateProxy>(
476497
wakeSession: config.wakeSession,
477498
})
478499

500+
const goalApi = createGoalApi({
501+
db: config.db,
502+
wakeSession: config.wakeSession,
503+
writeEvent: config.writeEvent,
504+
})
505+
479506
const listAttachments: AttachmentsApi[`list`] = (filter) => {
480507
const attachments = config.db.collections.manifests.toArray
481508
.filter((entry) => entry.kind === `attachment`)
@@ -713,7 +740,10 @@ export function createHandlerContext<TState extends StateProxy = StateProxy>(
713740
}
714741

715742
const agent: AgentHandle = {
716-
async run(input?: string): Promise<AgentRunResult> {
743+
async run(
744+
input?: string,
745+
abortSignal?: AbortSignal
746+
): Promise<AgentRunResult> {
717747
if (!agentConfig) {
718748
throw new Error(
719749
`[agent-runtime] agent.run() called without useAgent().`
@@ -756,6 +786,7 @@ export function createHandlerContext<TState extends StateProxy = StateProxy>(
756786

757787
onPayload: activeAgentConfig.onPayload,
758788

789+
onStepEnd: activeAgentConfig.onStepEnd,
759790
modelTimeoutMs: activeAgentConfig.modelTimeoutMs,
760791
modelMaxRetries: activeAgentConfig.modelMaxRetries,
761792
})
@@ -805,7 +836,11 @@ export function createHandlerContext<TState extends StateProxy = StateProxy>(
805836
)
806837
}
807838

808-
await handle.run(runInput, config.runSignal)
839+
const combinedSignal =
840+
config.runSignal && abortSignal
841+
? combineAbortSignals(config.runSignal, abortSignal)
842+
: (abortSignal ?? config.runSignal)
843+
await handle.run(runInput, combinedSignal)
809844
runtimeLog.info(logPrefix, `agent.run completed`)
810845

811846
return {
@@ -950,6 +985,11 @@ export function createHandlerContext<TState extends StateProxy = StateProxy>(
950985
removeContext: contextApi.removeContext,
951986
getContext: contextApi.getContext,
952987
listContext: contextApi.listContext,
988+
setGoal: goalApi.setGoal,
989+
clearGoal: goalApi.clearGoal,
990+
getGoal: goalApi.getGoal,
991+
markGoalComplete: goalApi.markGoalComplete,
992+
updateGoalUsage: goalApi.updateGoalUsage,
953993
__debug: {
954994
useContextRegistrations: () => useContextRegistrations,
955995
},
@@ -1043,6 +1083,53 @@ export function createHandlerContext<TState extends StateProxy = StateProxy>(
10431083
},
10441084
}
10451085
},
1086+
// Renders `text` as an ordinary assistant message in the chat without
1087+
// calling the LLM. Used for runtime-driven replies like slash-command
1088+
// responses and budget-limit notices. The five writes synthesize the
1089+
// same run + text + delta event sequence the outbound bridge would
1090+
// emit for a real LLM turn; the UI needs all of them to render.
1091+
replyText(text: string): void {
1092+
if (typeof text !== `string` || text.length === 0) return
1093+
const runKey = nextRunKey()
1094+
const msgKey = `${runKey}:msg`
1095+
config.writeEvent(
1096+
entityStateSchema.runs.insert({
1097+
key: runKey,
1098+
value: { status: `started` } as never,
1099+
}) as ChangeEvent
1100+
)
1101+
config.writeEvent(
1102+
entityStateSchema.texts.insert({
1103+
key: msgKey,
1104+
value: { status: `streaming`, run_id: runKey } as never,
1105+
}) as ChangeEvent
1106+
)
1107+
config.writeEvent(
1108+
entityStateSchema.textDeltas.insert({
1109+
key: `${msgKey}:0`,
1110+
value: {
1111+
text_id: msgKey,
1112+
run_id: runKey,
1113+
delta: text,
1114+
} as never,
1115+
}) as ChangeEvent
1116+
)
1117+
config.writeEvent(
1118+
entityStateSchema.texts.update({
1119+
key: msgKey,
1120+
value: { status: `completed`, run_id: runKey } as never,
1121+
}) as ChangeEvent
1122+
)
1123+
config.writeEvent(
1124+
entityStateSchema.runs.update({
1125+
key: runKey,
1126+
value: {
1127+
status: `completed`,
1128+
finish_reason: `stop`,
1129+
} as never,
1130+
}) as ChangeEvent
1131+
)
1132+
},
10461133
sleep(): void {
10471134
sleepRequested = true
10481135
},
@@ -1054,5 +1141,8 @@ export function createHandlerContext<TState extends StateProxy = StateProxy>(
10541141
},
10551142
}
10561143

1057-
return { ctx, getSleepRequested: () => sleepRequested }
1144+
return {
1145+
ctx,
1146+
getSleepRequested: () => sleepRequested,
1147+
}
10581148
}

0 commit comments

Comments
 (0)