@slopus/happy-agent-base 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +434 -34
- package/dist/Agent.d.ts +64 -9
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js +247 -40
- package/dist/Agent.js.map +1 -1
- package/dist/AgentBase.d.ts +270 -23
- package/dist/AgentBase.d.ts.map +1 -1
- package/dist/AgentBase.js +1728 -342
- package/dist/AgentBase.js.map +1 -1
- package/dist/AgentBaseHooks.d.ts +108 -20
- package/dist/AgentBaseHooks.d.ts.map +1 -1
- package/dist/AgentBasePending.d.ts +49 -0
- package/dist/AgentBasePending.d.ts.map +1 -0
- package/dist/AgentBasePending.js +73 -0
- package/dist/AgentBasePending.js.map +1 -0
- package/dist/AgentBaseState.d.ts +2 -0
- package/dist/AgentBaseState.d.ts.map +1 -1
- package/dist/AgentBaseStoreLock.d.ts +16 -0
- package/dist/AgentBaseStoreLock.d.ts.map +1 -0
- package/dist/AgentBaseStoreLock.js +37 -0
- package/dist/AgentBaseStoreLock.js.map +1 -0
- package/dist/AgentConfig.d.ts +67 -0
- package/dist/AgentConfig.d.ts.map +1 -0
- package/dist/AgentConfig.js +81 -0
- package/dist/AgentConfig.js.map +1 -0
- package/dist/AgentContexts.d.ts +40 -0
- package/dist/AgentContexts.d.ts.map +1 -0
- package/dist/AgentContexts.js +70 -0
- package/dist/AgentContexts.js.map +1 -0
- package/dist/AgentFeature.d.ts +105 -15
- package/dist/AgentFeature.d.ts.map +1 -1
- package/dist/AgentKV.d.ts +63 -0
- package/dist/AgentKV.d.ts.map +1 -0
- package/dist/AgentKV.js +122 -0
- package/dist/AgentKV.js.map +1 -0
- package/dist/AgentModel.d.ts +17 -0
- package/dist/AgentModel.d.ts.map +1 -0
- package/dist/AgentModel.js +2 -0
- package/dist/AgentModel.js.map +1 -0
- package/dist/AgentPersistence.d.ts +95 -0
- package/dist/AgentPersistence.d.ts.map +1 -0
- package/dist/AgentPersistence.js +2 -0
- package/dist/AgentPersistence.js.map +1 -0
- package/dist/AgentProviders.d.ts +5 -0
- package/dist/AgentProviders.d.ts.map +1 -1
- package/dist/AgentProviders.js +6 -0
- package/dist/AgentProviders.js.map +1 -1
- package/dist/AgentRef.d.ts +41 -0
- package/dist/AgentRef.d.ts.map +1 -0
- package/dist/AgentRef.js +60 -0
- package/dist/AgentRef.js.map +1 -0
- package/dist/AgentStorage.d.ts +19 -0
- package/dist/AgentStorage.d.ts.map +1 -0
- package/dist/AgentStorage.js +16 -0
- package/dist/AgentStorage.js.map +1 -0
- package/dist/AgentSystem.d.ts +50 -0
- package/dist/AgentSystem.d.ts.map +1 -0
- package/dist/AgentSystem.js +2 -0
- package/dist/AgentSystem.js.map +1 -0
- package/dist/AgentSystemContext.d.ts +15 -0
- package/dist/AgentSystemContext.d.ts.map +1 -0
- package/dist/AgentSystemContext.js +20 -0
- package/dist/AgentSystemContext.js.map +1 -0
- package/dist/AgentSystemLocal.d.ts +103 -0
- package/dist/AgentSystemLocal.d.ts.map +1 -0
- package/dist/AgentSystemLocal.js +282 -0
- package/dist/AgentSystemLocal.js.map +1 -0
- package/dist/AgentSystemRef.d.ts +56 -0
- package/dist/AgentSystemRef.d.ts.map +1 -0
- package/dist/AgentSystemRef.js +75 -0
- package/dist/AgentSystemRef.js.map +1 -0
- package/dist/AgentTaskContext.d.ts +17 -0
- package/dist/AgentTaskContext.d.ts.map +1 -0
- package/dist/AgentTaskContext.js +26 -0
- package/dist/AgentTaskContext.js.map +1 -0
- package/dist/AgentTool.d.ts +40 -0
- package/dist/AgentTool.d.ts.map +1 -1
- package/dist/AgentTool.js.map +1 -1
- package/dist/index.d.ts +20 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -2
- package/dist/index.js.map +1 -1
- package/dist/models.d.ts +62 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +185 -0
- package/dist/models.js.map +1 -0
- package/package.json +5 -3
- package/dist/AgentBaseContext.d.ts +0 -22
- package/dist/AgentBaseContext.d.ts.map +0 -1
- package/dist/AgentBaseContext.js +0 -33
- package/dist/AgentBaseContext.js.map +0 -1
- package/dist/AgentBasePersistence.d.ts +0 -60
- package/dist/AgentBasePersistence.d.ts.map +0 -1
- package/dist/AgentBasePersistence.js +0 -2
- package/dist/AgentBasePersistence.js.map +0 -1
package/README.md
CHANGED
|
@@ -9,6 +9,17 @@ assistant reply joins the history. When the model stops for tool calls, the agen
|
|
|
9
9
|
and feeds the results back. The conversation is observable only through hooks; there is no
|
|
10
10
|
external transcript or status surface.
|
|
11
11
|
|
|
12
|
+
## Prepared host features
|
|
13
|
+
|
|
14
|
+
The package also exports isolated `execution`, `git`, `workspaces`, `permissions`, `user-input`,
|
|
15
|
+
`image-generation`, `search`, `secrets`, and `workflows` features. Each directory owns its tool
|
|
16
|
+
array, agent hooks, direct public API, TypeBox boundary schemas, injected host ports, and focused
|
|
17
|
+
tests. The features do not import one another; an application composes their ports at its own
|
|
18
|
+
boundary.
|
|
19
|
+
|
|
20
|
+
These features are additive migration preparation. They do not switch Rig to this package or
|
|
21
|
+
remove `rig-execution`.
|
|
22
|
+
|
|
12
23
|
## Core API
|
|
13
24
|
|
|
14
25
|
```ts
|
|
@@ -18,15 +29,27 @@ class AgentBase {
|
|
|
18
29
|
readonly id: string;
|
|
19
30
|
readonly state: AgentBaseState; // the agent's own copy, mutable directly
|
|
20
31
|
|
|
21
|
-
steer(
|
|
22
|
-
|
|
32
|
+
steer(
|
|
33
|
+
ctx: Context,
|
|
34
|
+
message: SessionUserMessage,
|
|
35
|
+
options?: AgentBaseMessageOptions & AgentBaseAwaitOptions,
|
|
36
|
+
): Promise<void>;
|
|
37
|
+
send(
|
|
38
|
+
ctx: Context,
|
|
39
|
+
message: SessionUserMessage,
|
|
40
|
+
options?: AgentBaseMessageOptions & AgentBaseAwaitOptions,
|
|
41
|
+
): Promise<void>;
|
|
23
42
|
start(): void;
|
|
24
|
-
abort(): Promise<void>;
|
|
25
|
-
compact(ctx: Context): Promise<void>;
|
|
43
|
+
abort(ctx: Context, options?: AgentBaseAwaitOptions): Promise<void>;
|
|
44
|
+
compact(ctx: Context, options?: AgentBaseAwaitOptions): Promise<void>;
|
|
26
45
|
waitForIdle(): Promise<void>;
|
|
27
46
|
close(): Promise<void>;
|
|
28
47
|
}
|
|
29
48
|
|
|
49
|
+
interface AgentBaseAwaitOptions {
|
|
50
|
+
await?: boolean; // default false: return once the agent has taken the request on
|
|
51
|
+
}
|
|
52
|
+
|
|
30
53
|
interface AgentBaseOptions {
|
|
31
54
|
id: string;
|
|
32
55
|
providers: AgentProviders;
|
|
@@ -48,15 +71,94 @@ interface AgentBaseState {
|
|
|
48
71
|
|
|
49
72
|
interface AgentBaseHooks {
|
|
50
73
|
onEvent?: (ctx: Context, event: SessionEvent) => void;
|
|
51
|
-
instructions?: (ctx: Context) => string
|
|
52
|
-
tools?: (ctx: Context) => readonly AnyAgentTool[]
|
|
53
|
-
|
|
74
|
+
instructions?: (ctx: Context) => MaybePromise<string>; // extends state.instructions
|
|
75
|
+
tools?: (ctx: Context) => MaybePromise<readonly AnyAgentTool[]>; // extends state.tools
|
|
76
|
+
aroundToolExecution?: (
|
|
77
|
+
ctx: Context,
|
|
78
|
+
execution: AgentBaseToolExecution,
|
|
79
|
+
) => MaybePromise<unknown>; // after argument validation, immediately around execute
|
|
80
|
+
modelChanged?: (
|
|
81
|
+
ctx: Context,
|
|
82
|
+
change: AgentBaseModelChange,
|
|
83
|
+
) => MaybePromise<SessionSystemMessage | undefined>;
|
|
54
84
|
beforeAgentLoop?: (ctx: Context) => void;
|
|
55
|
-
beforeTurn?: (
|
|
85
|
+
beforeTurn?: (
|
|
86
|
+
ctx: Context,
|
|
87
|
+
turn: AgentBaseTurnStart,
|
|
88
|
+
) => MaybePromise<readonly AgentFeatureAction[] | undefined>;
|
|
56
89
|
beforeInference?: (ctx: Context) => void;
|
|
57
|
-
afterInference?: (ctx: Context) => void
|
|
58
|
-
afterTurn?: (
|
|
59
|
-
|
|
90
|
+
afterInference?: (ctx: Context, inference: AgentBaseInference) => MaybePromise<void>;
|
|
91
|
+
afterTurn?: (
|
|
92
|
+
ctx: Context,
|
|
93
|
+
turn: AgentBaseTurn,
|
|
94
|
+
) => MaybePromise<readonly AgentFeatureAction[] | undefined>;
|
|
95
|
+
afterAgentLoop?: (ctx: Context) => MaybePromise<readonly AgentFeatureAction[] | undefined>;
|
|
96
|
+
afterAgentSettled?: (ctx: Context) => MaybePromise<void>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface AgentBaseToolExecution {
|
|
100
|
+
callId: string;
|
|
101
|
+
tool: AnyAgentTool;
|
|
102
|
+
arguments: unknown;
|
|
103
|
+
execute(): Promise<unknown>; // repeated calls join the same downstream execution
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface AgentBaseInference {
|
|
107
|
+
state: SessionDoneState | undefined;
|
|
108
|
+
tokens: SessionTokens | undefined; // absent when the response was cancelled or failed
|
|
109
|
+
errorMessage?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface AgentBaseTurnStart {
|
|
113
|
+
contextTokens: number | undefined; // measured size of the context this turn runs on
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface AgentBaseTurn extends AgentBaseTurnStart {
|
|
117
|
+
aborted: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface AgentEnvironment {
|
|
121
|
+
osVersion: string;
|
|
122
|
+
platform: AgentPlatform; // the platforms Node reports
|
|
123
|
+
workingDirectory: string;
|
|
124
|
+
shell: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface AgentConfig {
|
|
128
|
+
environment?: AgentEnvironment; // all of it, or none of it
|
|
129
|
+
features?: { [featureName: string]: { [key: string]: unknown } };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface AgentFeature {
|
|
133
|
+
name: string;
|
|
134
|
+
// Plus any subset of AgentBaseHooks, each taking its AgentFeatureScope after the context.
|
|
135
|
+
// aroundToolExecution wrappers nest in feature order.
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface AgentFeatureScope {
|
|
139
|
+
agent: AgentFeatureAgent;
|
|
140
|
+
kv: AgentBaseKV; // this feature's store for this agent, outliving every run
|
|
141
|
+
sharedKV: AgentBaseKV; // this feature's store, shared by every agent in the collection
|
|
142
|
+
runKV: AgentBaseKV; // this feature's store for the run, erased when the agent settles
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface AgentFeatureAgent {
|
|
146
|
+
id: string;
|
|
147
|
+
provider: string; // registry ID
|
|
148
|
+
providerKind: ProviderModelCompatibilityType | undefined; // how that ID was registered
|
|
149
|
+
model: string | undefined;
|
|
150
|
+
effort: SessionReasoningEffort | undefined;
|
|
151
|
+
tier: SessionServiceTier | undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
class AgentBaseKV {
|
|
155
|
+
readonly prefix: string; // absolute key prefix of this scope, ending with "."
|
|
156
|
+
scoped(...segments: string[]): AgentBaseKV; // narrower store under `segments`
|
|
157
|
+
read(ctx: Context, key: string): Promise<unknown>;
|
|
158
|
+
list(ctx: Context, prefix?: string): Promise<readonly { key: string; value: unknown }[]>;
|
|
159
|
+
write(ctx: Context, key: string, value: unknown): Promise<void>;
|
|
160
|
+
delete(ctx: Context, key: string): Promise<void>;
|
|
161
|
+
clear(ctx: Context): Promise<void>; // every entry in the scope, including narrower ones
|
|
60
162
|
}
|
|
61
163
|
|
|
62
164
|
interface AgentBaseModelChange {
|
|
@@ -85,6 +187,14 @@ type AgentFeatureAction =
|
|
|
85
187
|
interface AgentTool<Args extends TSchema = TSchema, Result extends TSchema = TSchema> {
|
|
86
188
|
// The provider-facing descriptor fields of SessionTool, with parameters typed as Args, plus:
|
|
87
189
|
durable?: boolean;
|
|
190
|
+
autoPermissionInstructions?: string;
|
|
191
|
+
describeAutoPermissionAction?: (args: Static<Args>, ctx: Context) => string;
|
|
192
|
+
requiresAutoOrFullAccess?: boolean;
|
|
193
|
+
shouldReviewInAutoMode: (args: Static<Args>, ctx: Context) => boolean | Promise<boolean>;
|
|
194
|
+
shouldRunInFullAccessInAutoMode?: (
|
|
195
|
+
args: Static<Args>,
|
|
196
|
+
ctx: Context,
|
|
197
|
+
) => boolean | Promise<boolean>;
|
|
88
198
|
returnType: Result;
|
|
89
199
|
execute(ctx: Context, args: Static<Args>): Promise<Static<Result>>;
|
|
90
200
|
toLLM(result: Static<Result>): readonly SessionOutputBlock[];
|
|
@@ -102,10 +212,22 @@ interface AgentBasePersistence {
|
|
|
102
212
|
clearRecords(ctx: Context): Promise<void>; // physical delete, used inside the compaction transaction
|
|
103
213
|
readValues(ctx: Context, prefix: string): Promise<readonly { key: string; value: unknown }[]>;
|
|
104
214
|
writeValue(ctx: Context, key: string, value: unknown): Promise<void>;
|
|
215
|
+
writeValueIfAbsent(ctx: Context, key: string, value: unknown): Promise<boolean>; // claim
|
|
216
|
+
writeValueIfUnchanged( // compare and set
|
|
217
|
+
ctx: Context,
|
|
218
|
+
key: string,
|
|
219
|
+
expected: unknown,
|
|
220
|
+
value: unknown,
|
|
221
|
+
): Promise<boolean>;
|
|
105
222
|
deleteValue(ctx: Context, key: string): Promise<void>;
|
|
223
|
+
deleteValueIfPresent(ctx: Context, key: string): Promise<boolean>; // claim
|
|
106
224
|
}
|
|
107
225
|
```
|
|
108
226
|
|
|
227
|
+
The three conditional operations are what make one storage safe to share between owners who
|
|
228
|
+
cannot see each other: each performs its check and its write as a single atomic step, so of two
|
|
229
|
+
owners racing for one key exactly one is told it won.
|
|
230
|
+
|
|
109
231
|
Persistence is an append-only main context store plus a sorted key-value store alongside it. The
|
|
110
232
|
agent serializes every operation through one internal lock (configured to crash on re-entry), so
|
|
111
233
|
implementations never see concurrent calls and need no locking of their own.
|
|
@@ -127,12 +249,12 @@ queued message still safely waiting.
|
|
|
127
249
|
|
|
128
250
|
The two queues give four delivery strategies, mirroring Pi:
|
|
129
251
|
|
|
130
|
-
| Strategy
|
|
131
|
-
|
|
132
|
-
| Steering + one-at-a-time | `steer` queues FIFO. After the current assistant response and all its tool calls finish, the oldest message injects, gets a response, then the next is handled.
|
|
133
|
-
| Steering + all
|
|
134
|
-
| Send + one-at-a-time
|
|
135
|
-
| Send + all
|
|
252
|
+
| Strategy | Behavior |
|
|
253
|
+
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
254
|
+
| Steering + one-at-a-time | `steer` queues FIFO. After the current assistant response and all its tool calls finish, the oldest message injects, gets a response, then the next is handled. |
|
|
255
|
+
| Steering + all | After the current response and tool batch finish, every queued steering message injects together before one response. |
|
|
256
|
+
| Send + one-at-a-time | `send` waits until the agent would otherwise stop — no tool calls or steering remain — then injects one message and waits for its response before draining another. |
|
|
257
|
+
| Send + all | Once the agent would otherwise stop, every queued sent message injects together before one response. |
|
|
136
258
|
|
|
137
259
|
Both modes default to `"one-at-a-time"`, and steering always takes precedence over sent messages.
|
|
138
260
|
Queue consumption happens only between inferences — never mid-stream and never during a tool
|
|
@@ -157,6 +279,8 @@ hook fires on every selection change with the old and new model, both provider I
|
|
|
157
279
|
instances, the registry, and the `wasReset` flag; on a reset the handoff system message it
|
|
158
280
|
returns is injected at the very beginning of the fresh context — without one the context starts
|
|
159
281
|
completely empty. The consumed message that carried the new selection follows the handoff. A
|
|
282
|
+
hook failure during an incompatible change rejects the switch outright: the previous selection
|
|
283
|
+
stays effective and the history is not cleared. A
|
|
160
284
|
thrown provider or load failure is reported to the `onEvent` hook as an `internal_error` done
|
|
161
285
|
event instead of rejecting the loop. The agent never retries inference itself — providers own
|
|
162
286
|
retry semantics and surface them as `retrying` events. A provider-reported error response ends
|
|
@@ -201,31 +325,64 @@ the history, and asks the provider to compact it. The completed replacement cont
|
|
|
201
325
|
the compacted history while any message that joined after the snapshot is kept. In one atomic
|
|
202
326
|
transaction the superseded records are physically deleted and the replacement — the messages
|
|
203
327
|
that stay — is appended as a `compaction` record, which then opens the store while later
|
|
204
|
-
records append as usual.
|
|
205
|
-
|
|
206
|
-
reports failure, leaving
|
|
328
|
+
records append as usual. `compact` returns once the compaction has been asked for; with `await: true` it returns once it
|
|
329
|
+
has run, and callers waiting while one is pending or running all wait for that same shared
|
|
330
|
+
compaction, which resolves on completion and rejects when the provider reports failure, leaving
|
|
331
|
+
the history untouched.
|
|
207
332
|
|
|
208
333
|
Hooks receive the agent's context first. That context — shared by tool executions — is derived
|
|
209
|
-
once at construction and carries the agent's provider registry ID, model, effort, and
|
|
210
|
-
service tier — all serializable values — readable through the exported `
|
|
211
|
-
`agentBaseModel`, `agentBaseEffort`, and `agentBaseServiceTier` accessors.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
334
|
+
once at construction and carries the agent's ID, provider registry ID, model, effort, and
|
|
335
|
+
service tier — all serializable values — readable through the exported `agentBaseId`,
|
|
336
|
+
`agentBaseProvider`, `agentBaseModel`, `agentBaseEffort`, and `agentBaseServiceTier` accessors.
|
|
337
|
+
The
|
|
338
|
+
`instructions` and `tools` hooks extend the mutable state — the state comes first, the hook's
|
|
339
|
+
answer follows — and are consulted for session creation, every inference request, compaction,
|
|
340
|
+
and tool lookup. They are correctness hooks: a failure there, including two tools sharing one
|
|
341
|
+
name and namespace in the merged list, fails the turn loudly instead of silently running with a
|
|
342
|
+
wrong configuration. Before every inference the provider-facing configuration is compared with
|
|
343
|
+
the one the session was created with, and a changed prompt or changed tool descriptors recreate
|
|
344
|
+
the provider session so the model always sees the tools the agent would actually execute. Both
|
|
345
|
+
hooks — like `modelChanged`, `afterTurn`, and `afterAgentLoop` — may return promises.
|
|
346
|
+
|
|
347
|
+
The context also carries a scoped key-value store, readable through the exported `agentBaseKV`
|
|
348
|
+
accessor. The store is an `AgentBaseKV` view over the agent's sorted store under
|
|
349
|
+
`kv.<agent id>.`, executing every operation through the agent's own persistence lock, with keys
|
|
350
|
+
always relative to the scope — a holder can neither see nor touch anything outside it, and
|
|
351
|
+
`scoped(segment)` narrows further. Hooks receive the session scope; a tool execution receives
|
|
352
|
+
the store narrowed to `call.<call ID>`, so a tool call persists under its own call ID and never
|
|
353
|
+
in another call's scope. Beside it the context carries a second store of the same shape, under
|
|
354
|
+
`kv.<agent id>.run.` and readable through `agentRunKV`, which belongs to the run rather than to
|
|
355
|
+
the conversation: the transaction that settles the agent erases the whole of it, so what a run
|
|
356
|
+
wrote about itself never reaches the next one. The `modelChanged` hook fires while the agent
|
|
357
|
+
holds its persistence lock, so its stores execute directly on the held lock instead of
|
|
358
|
+
deadlocking.
|
|
216
359
|
|
|
217
360
|
The lifecycle hooks bracket the loop's own structure. `beforeAgentLoop` fires when the loop
|
|
218
361
|
leaves the settled state and begins working, and `afterAgentLoop` fires when it would settle
|
|
219
362
|
back to idle; between them, each turn is bracketed by `beforeTurn` and `afterTurn`, and each
|
|
220
|
-
inference request inside a turn by `beforeInference` and `afterInference`. `
|
|
221
|
-
`afterAgentLoop` may return an array of `AgentFeatureAction`s, all applied
|
|
222
|
-
loop continues: `steer` and `send` queue a message through the ordinary
|
|
223
|
-
as the public methods do, and `compact` triggers the shared compaction.
|
|
224
|
-
|
|
225
|
-
|
|
363
|
+
inference request inside a turn by `beforeInference` and `afterInference`. `beforeTurn`,
|
|
364
|
+
`afterTurn`, and `afterAgentLoop` may return an array of `AgentFeatureAction`s, all applied
|
|
365
|
+
together before the loop continues: `steer` and `send` queue a message through the ordinary
|
|
366
|
+
durable queues exactly as the public methods do, and `compact` triggers the shared compaction.
|
|
367
|
+
Actions from `beforeTurn` are carried out by the turn that is about to run — a compaction it
|
|
368
|
+
asks for happens before that turn's first inference; actions from `afterTurn` drive the loop
|
|
369
|
+
into another turn within the same loop span; actions from `afterAgentLoop` reopen the loop
|
|
370
|
+
instead of settling. Like every hook, a thrown lifecycle hook — or a failing action —
|
|
226
371
|
never fails the run.
|
|
227
372
|
|
|
228
|
-
|
|
373
|
+
The agent tracks the conversation's true size from the provider's own token counts, so nothing
|
|
374
|
+
has to estimate it or watch the event stream for it. `afterInference` receives how each response
|
|
375
|
+
ended and the counts it measured — the complete input context the provider received plus the
|
|
376
|
+
output it generated, which is where the next request starts from. Their sum becomes the agent's
|
|
377
|
+
context size, persisted under the `context` key and restored on load, so a restarted agent knows
|
|
378
|
+
how large its conversation is before it runs anything. A cancelled or failed response measures
|
|
379
|
+
nothing and reports no counts, leaving the last real measurement in place; a completed
|
|
380
|
+
compaction clears it, since the conversation it described is gone. Both turn hooks carry that
|
|
381
|
+
size as `contextTokens`, allowing an external feature to return a `compact` action from
|
|
382
|
+
`beforeTurn` when its own threshold is reached.
|
|
383
|
+
|
|
384
|
+
`abort` cancels the active turn; when idle it is a no-op. It returns once the cancellation has
|
|
385
|
+
been signalled, and with `await: true` once the loop has actually unwound.
|
|
229
386
|
The inference stream is abandoned and asked to close, a `done` event with state `cancelled` is
|
|
230
387
|
emitted, blocks that already finished stay in the history while an unfinished block is dropped
|
|
231
388
|
everywhere, and each still-running tool call settles as an error tool result saying it was
|
|
@@ -233,6 +390,86 @@ aborted — consuming its pending `tool.` entry so the batch leaves a complete c
|
|
|
233
390
|
The queued turn request is dropped too, but messages still waiting in the steering and
|
|
234
391
|
send queues stay durable and join the next requested turn.
|
|
235
392
|
|
|
393
|
+
`Agent` is a thin wrapper around `AgentBase` that assembles its behavior from an array of
|
|
394
|
+
`AgentFeature`s instead of one hooks object. Each feature carries a required stable `name` and
|
|
395
|
+
implements any subset of the hooks; the agent merges them, in array order, into the singular
|
|
396
|
+
private hooks its internal base runs with. Every hook receives the agent's context first and its
|
|
397
|
+
own `AgentFeatureScope` second: the agent it is serving — identity, provider registry ID and
|
|
398
|
+
kind, model, effort, and tier — and its three stores, each narrowed to `feature.<name>`, so
|
|
399
|
+
features never see each other's persisted entries and renaming a feature orphans everything it
|
|
400
|
+
stored. `kv` belongs to that one agent's conversation, `sharedKV` to the whole collection, and
|
|
401
|
+
`runKV` to the run in progress and is erased when it settles. They are handed over rather than
|
|
402
|
+
read off the context, so a hook is given exactly what it is entitled to and can never be passed
|
|
403
|
+
a context that quietly means another agent. Features are independent: observing hooks — events and lifecycle brackets — fan out with
|
|
404
|
+
per-feature isolation so one throwing feature never silences the others, and lifecycle actions
|
|
405
|
+
concatenate with a failing feature losing only its own actions. Instructions and tools
|
|
406
|
+
concatenate after the base state and stay loud: a failing feature fails the turn. For a model
|
|
407
|
+
change every feature observes the change, the first returned handoff wins, and a feature
|
|
408
|
+
failure during an incompatible change rejects the switch so the history survives. `feature(name)`
|
|
409
|
+
hands back the instance running under that name, which is how the owner of an agent reaches what
|
|
410
|
+
belongs to it — a goal to pause, for instance.
|
|
411
|
+
|
|
412
|
+
`AgentSystem` is the type of a collection of agents, and `AgentSystemLocal` is the implementation
|
|
413
|
+
that lazily resolves and owns the `Agent` instances of one. An agent exists only
|
|
414
|
+
once `create(ctx, config)` has generated its cuid2 identity: the `AgentConfig` is validated,
|
|
415
|
+
persisted under the collection's storage, and then stays in effect for the agent's whole life,
|
|
416
|
+
so `resolve` on an ID that was never created is an error and `create` on one that already
|
|
417
|
+
exists is too. The configuration carries the environment the agent works on — `osVersion`,
|
|
418
|
+
`platform`, `workingDirectory`, and `shell`, all of them or none, so nothing the agent is told
|
|
419
|
+
about its machine is ever half-true — plus one opaque settings map per feature, keyed by feature
|
|
420
|
+
name; the agent never looks inside a feature's entry, so a feature validates its own against its
|
|
421
|
+
own schema. Every context the agent derives carries the configuration, readable
|
|
422
|
+
through the exported `agentConfig` and `agentFeatureConfig` accessors, from the first hook of a
|
|
423
|
+
feature all the way down to a tool execution.
|
|
424
|
+
|
|
425
|
+
A collection is given its features as instances the caller has already built and which are ready
|
|
426
|
+
to serve — there is no load step. One instance serves every agent the collection builds, so it
|
|
427
|
+
learns which agent a hook is running for from the scope it is handed rather than from anything
|
|
428
|
+
it was constructed with, and keeps what one run remembers keyed by that ID, dropping it when the
|
|
429
|
+
agent settles.
|
|
430
|
+
|
|
431
|
+
`start(ctx)` resolves and resumes every agent that was still
|
|
432
|
+
working when the previous process stopped, and `steer`, `send`, `abort`, and `compact` resolve
|
|
433
|
+
an agent by ID before acting on it, forwarding the same options the agent takes.
|
|
434
|
+
A feature's `sharedKV` is durable storage shared by every agent in the collection and outliving
|
|
435
|
+
all of them — an agent's own store belongs to its conversation and is cleared when the ID is
|
|
436
|
+
created again, so work one agent owes another lives here instead. `delete` closes an
|
|
437
|
+
agent and releases its identity while leaving what it wrote in place; creating the ID again is
|
|
438
|
+
what clears the store, so the new agent never wakes up inside its predecessor's conversation.
|
|
439
|
+
|
|
440
|
+
Asking and waiting are separate everywhere. `steer`, `send`, `abort`, and `compact` all return
|
|
441
|
+
once the agent has taken the request on, and `await: true` asks for the part only the run loop
|
|
442
|
+
can give — the durable write, the finished compaction, the unwound turn. That flag is refused,
|
|
443
|
+
with an error naming the problem, when the caller's context says it is running inside the loop of
|
|
444
|
+
the agent it is asking: a hook or a tool runs while its own agent's loop waits for it, so waiting
|
|
445
|
+
for that agent is waiting for itself. The check is per agent, so work inside one agent's loop may
|
|
446
|
+
still wait on another's — which is what makes a subagent's report to its parent safe. Two agents
|
|
447
|
+
each inside a tool are the one cross-agent case that cannot be allowed to wait: a waited-for
|
|
448
|
+
`compact` on an agent that is running a tool, asked from inside a turn, is refused outright,
|
|
449
|
+
because the tool it would wait for may be waiting for the caller. Note that
|
|
450
|
+
an operation nobody waits for still reports nothing: a fire-and-forget `send` whose durable write
|
|
451
|
+
fails is a message that silently never arrives.
|
|
452
|
+
|
|
453
|
+
`AgentSystemRef` and `AgentRef` are the same collection seen from inside an agent, and the only
|
|
454
|
+
form of it a context ever carries: a collection puts a reference on every context it derives, so
|
|
455
|
+
a feature hook or a tool — code some loop is waiting for — can never reach an operation that
|
|
456
|
+
waits for a loop. `close`, `waitForIdle`, `delete`, and `start` are absent entirely, `create` and
|
|
457
|
+
`resolve` hand back an `AgentRef` rather than the `Agent` that would carry them, and `compact`
|
|
458
|
+
and `abort` are requests that return once they have been made. The returned `AgentRef.id` is the
|
|
459
|
+
same system-generated cuid2 used for all later addressing.
|
|
460
|
+
|
|
461
|
+
A message is the one thing a caller here is told about, because accepting one is a durable queue
|
|
462
|
+
write rather than a turn: addressed to another agent, `steer` and `send` resolve once the message
|
|
463
|
+
really is part of that agent's conversation and reject when the write fails, which is what lets a
|
|
464
|
+
child know its parent has its report. Addressed to the agent the caller is running inside — whose
|
|
465
|
+
loop would have to make that write — the message is queued and nothing is waited for. The
|
|
466
|
+
context decides, since it names the agent the caller is in; a context naming none proves nothing
|
|
467
|
+
and waits for nothing. The agent's own `await: true`, which also covers a finished compaction and
|
|
468
|
+
an unwound turn, is never offered here and never passed on.
|
|
469
|
+
|
|
470
|
+
`currentAgentEnvironment()` reads a complete environment from the running process, ready to hand
|
|
471
|
+
to `create`.
|
|
472
|
+
|
|
236
473
|
`AgentProviders` is a mutable registry of provider instances keyed by caller-supplied IDs, so the
|
|
237
474
|
same provider class can be registered under several IDs. `add(id, provider, type)` registers an
|
|
238
475
|
instance together with its compatibility type (`"claude"`, `"codex"`, `"grok"`, `"bedrock"`, or
|
|
@@ -249,3 +486,166 @@ pnpm --filter @slopus/happy-agent-base check
|
|
|
249
486
|
pnpm --filter @slopus/happy-agent-base test
|
|
250
487
|
pnpm --filter @slopus/happy-agent-base build
|
|
251
488
|
```
|
|
489
|
+
|
|
490
|
+
## Chaos tests
|
|
491
|
+
|
|
492
|
+
`tests/chaos/` breaks the agent on purpose, in every way the machinery claims to survive, and
|
|
493
|
+
checks that the durable state keeps its promises anyway. The suites share one harness: a disk
|
|
494
|
+
that outlives each process, a store that can die or misbehave on a seeded schedule, and a model
|
|
495
|
+
that answers from the conversation it is handed rather than from a script — so a restarted agent
|
|
496
|
+
hears exactly what its dead predecessor did, down to the call IDs. Tools refuse to run for a
|
|
497
|
+
process that has already died, so a zombie's side effect is never mistaken for the world's.
|
|
498
|
+
|
|
499
|
+
| Suite | What it breaks |
|
|
500
|
+
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
501
|
+
| `crashRecovery` | the process dies at a random operation, again and again, until one is finally left alone to finish |
|
|
502
|
+
| `queues` | a mixture of sent and steering messages, each accepted just before a crash |
|
|
503
|
+
| `compaction` | the one operation that destroys history, interrupted inside its transaction |
|
|
504
|
+
| `compactionUnderLoad` | the same, with messages still waiting in both queues and calls in flight |
|
|
505
|
+
| `interruption` | the turn is aborted at a random point in the event stream, sometimes while crashing too |
|
|
506
|
+
| `flakyStore` | writes fail and the process keeps running, so the agent has to cope rather than stop |
|
|
507
|
+
| `concurrency` | nothing crashes; messages, interruptions and starts simply arrive all at once |
|
|
508
|
+
| `modelSwitch` | an incompatible model change, which resets the conversation, crashed around |
|
|
509
|
+
| `ownershipRaces` | boundaries one owner's lock cannot cover: two live instances on one store, two managers claiming one identity, and lifecycle calls racing the work they own |
|
|
510
|
+
| `managerRecoveryRaces` | the seam between a live agent, its collection's discovery index, and a second collection over the same durable identity |
|
|
511
|
+
| `coreLoopConsistencyGaps` | the loop stopped at exact ownership handoffs: a lock that escapes its hook, a batch split in half, a phantom turn, a reentrant close, a contradicted terminal event |
|
|
512
|
+
| `featureConsistencyRaces` | features under partial failure: a half-finished spawn, a lost completion, a goal completed and paused at once, and scopes that collide through a dot |
|
|
513
|
+
|
|
514
|
+
Each suite runs 60 seeds by default. `CHAOS_SEEDS=5000 pnpm test` turns the same suites into a
|
|
515
|
+
long hunt for rarer interleavings; the whole set has been swept clean at that size.
|
|
516
|
+
|
|
517
|
+
What the seeds prove, however the run was broken:
|
|
518
|
+
|
|
519
|
+
- every message reaches the conversation exactly once — none lost between the durable queue and
|
|
520
|
+
the context, none replayed by the resume that followed, and each queue keeps its order across
|
|
521
|
+
restarts;
|
|
522
|
+
- every call is settled exactly once, no result exists without its call, and the conversation
|
|
523
|
+
never keeps a tool call the model will not get an answer for;
|
|
524
|
+
- a non-durable tool never runs twice, while a durable one may;
|
|
525
|
+
- nothing is reported as finished before it is durable;
|
|
526
|
+
- a compacted conversation comes back whole or replaced, never half-erased, and a message still
|
|
527
|
+
waiting in a queue survives the replacement;
|
|
528
|
+
- an incompatible model switch lands on one side or the other, never the old history under the
|
|
529
|
+
new model;
|
|
530
|
+
- the agent settles owing nothing: no queued message, no pending call, and a final answer — and
|
|
531
|
+
says so durably, so another process reads a committed fact rather than guessing;
|
|
532
|
+
- one accepted message is answered once even when two live owners hold the same store, and a
|
|
533
|
+
turn always answers the durable conversation rather than the one its instance remembers;
|
|
534
|
+
- one response has exactly one terminal event, and a store handed to a hook is a capability that
|
|
535
|
+
expires with the call.
|
|
536
|
+
|
|
537
|
+
Breaking things on purpose has paid for itself several times over. Every bug below was found by
|
|
538
|
+
a chaos seed and has its own focused test in `tests/blackbox/`:
|
|
539
|
+
|
|
540
|
+
- **Queue keys collided across a restart.** The sequence counter began again at zero in each
|
|
541
|
+
process, so two messages accepted in the same millisecond by two processes shared one key and
|
|
542
|
+
the first was overwritten. Keys are now ordered by what the store already holds.
|
|
543
|
+
- **A crash between a response's last block and the batch commit left a call nobody would ever
|
|
544
|
+
answer.** There was no pending entry to resume from. Such calls have certainly not run, since
|
|
545
|
+
the commit precedes every execution, so they are recovered as the batch they never got to be.
|
|
546
|
+
- **A turn that failed after emitting a call left it unanswered**, and the next message was
|
|
547
|
+
appended behind it. A turn now settles its own calls with an error result before it gives up.
|
|
548
|
+
- **A conversation could still be stranded** when even that failed, with the gap buried under
|
|
549
|
+
later messages. Load repairs it by rewriting the conversation atomically — the only way, since
|
|
550
|
+
the answer belongs beside its call rather than at the end.
|
|
551
|
+
- **A failed turn was never retried.** The note it leaves behind means the question was never
|
|
552
|
+
answered, so a restarted agent now owes a response and has the note for context.
|
|
553
|
+
- **A restart could answer a compaction.** Whether a restart owes a response is now decided by
|
|
554
|
+
the last durable record rather than the message it ends on: a consumed message, a tool result
|
|
555
|
+
or a failure note is owed an answer, while a replacement written by a compaction is not a
|
|
556
|
+
question and gets none. A replacement also keeps whatever joined the conversation after its
|
|
557
|
+
snapshot, and a consumed message kept that way is still owed an answer — which is not visible
|
|
558
|
+
in the messages, so the rewrite that knew records it on the record it writes.
|
|
559
|
+
- **Two owners of one agent ID could collide on a queue key**, and the second write silently
|
|
560
|
+
replaced a message whose `send` had already resolved. Every key now ends in a segment
|
|
561
|
+
identifying its writer, so simultaneous acceptance can order two messages arbitrarily but can
|
|
562
|
+
never lose one.
|
|
563
|
+
- **Compaction erased work it had never seen.** The suffix it preserved came from its own
|
|
564
|
+
memory, so a record another owner committed while the provider was summarizing was wiped by
|
|
565
|
+
the clear-and-replace. The boundary is now a record count taken from the store, and the suffix
|
|
566
|
+
is rebuilt from the store inside the commit.
|
|
567
|
+
- **Abort did not own the whole turn.** The abort scope now opens before the turn's hooks and
|
|
568
|
+
loading rather than at its first inference, so a turn cancelled during startup never reaches
|
|
569
|
+
the model at all.
|
|
570
|
+
- **A compaction requested during an aborted turn never settled.** Dropping the turn request
|
|
571
|
+
dropped the only thing that would have run it, leaving every `compact()` caller waiting for
|
|
572
|
+
ever. Abort and close now settle a compaction nobody will carry out.
|
|
573
|
+
- **A response's leftovers overlapped the next request.** A stream still closing, or a tool that
|
|
574
|
+
an abort settled in the conversation but which is still running, kept hold of a stateful
|
|
575
|
+
session while the next request went out. The next request now waits for that work — but an
|
|
576
|
+
abort does not, so a stream or tool that ignores cancellation can never hold a cancellation
|
|
577
|
+
open.
|
|
578
|
+
- **Close raced the work it had already accepted.** A `send` that had been admitted could still
|
|
579
|
+
be writing when `close()` resolved. Close is now a barrier: nothing new is admitted, and
|
|
580
|
+
everything already admitted is written, answered, and only then is the session destroyed.
|
|
581
|
+
- **Two managers could both create one agent ID**, each returning a live agent while storage
|
|
582
|
+
kept a single configuration. Creation now claims the identity with an atomic write-if-absent,
|
|
583
|
+
so exactly one creator is told it won.
|
|
584
|
+
- **A creation that failed halfway still took the name.** The configuration was committed before
|
|
585
|
+
the agent was built, so a feature that refused to load left an identity behind that no agent
|
|
586
|
+
answered to and no caller could claim again. A creation that produces no agent now rolls its
|
|
587
|
+
identity back, and a subagent spawn whose initial task never reached the child does the same.
|
|
588
|
+
- **A message could be acknowledged before anything could find the agent that owed it.** The
|
|
589
|
+
agent now records that it owes an answer in the same transaction that accepts the message, and
|
|
590
|
+
the collection publishes it in its discovery index before the send resolves.
|
|
591
|
+
- **Settling was inferred rather than committed.** The index deletion at the end of a settle
|
|
592
|
+
could erase a marker owed to a message accepted while that deletion was in flight. Settling is
|
|
593
|
+
now committed under the persistence lock and only when the durable queues really are empty, so
|
|
594
|
+
an agent that owes an answer is never durably described as settled — and discovery asks that
|
|
595
|
+
committed fact instead of reading another component's state.
|
|
596
|
+
- **One durable message could be answered twice.** Two live owners over one store each loaded it
|
|
597
|
+
into memory and each consumed it. A consumption now claims every entry with an atomic delete
|
|
598
|
+
inside its transaction, and a batch that wins nothing rolls back untouched.
|
|
599
|
+
- **A live owner could answer a conversation that no longer existed**, having loaded the durable
|
|
600
|
+
state once and kept it. Every turn now reloads before it decides anything, so a model switch
|
|
601
|
+
or an appended message from another owner is in force by the next turn.
|
|
602
|
+
- **A hook could keep the store it was lent.** `modelChanged` runs inside the persistence lock
|
|
603
|
+
and receives a store bound to that hold; retaining it let later writes bypass the lock
|
|
604
|
+
entirely. The store is now a capability released when the hook returns.
|
|
605
|
+
- **An external send could land in the middle of a hook's decision.** Two messages returned by
|
|
606
|
+
one hook were written one lock hold at a time. They are now accepted as one batch, so a caller
|
|
607
|
+
arriving during it lands after all of it.
|
|
608
|
+
- **A send accepted mid-inference bought an empty turn.** The running turn drained it but left
|
|
609
|
+
the request flag raised, so the loop ran again with nothing to do and fired a full set of
|
|
610
|
+
lifecycle hooks around it. Consuming the last queued work now clears the request it answered.
|
|
611
|
+
- **Close could destroy one session twice.** An idle agent reached `destroy()` before the shared
|
|
612
|
+
shutdown promise had been assigned, so a participant reentering from inside `destroy` started
|
|
613
|
+
a second shutdown. The barrier is now published before any of the shutdown runs.
|
|
614
|
+
- **One response could report two terminal outcomes.** An abort observed after a normal `done`
|
|
615
|
+
appended a contradictory `cancelled` event for the same response. A cancellation is now only
|
|
616
|
+
reported when the turn actually had something left to cancel.
|
|
617
|
+
- **Two scopes could collide through a dot.** A feature named `alpha.beta` and the relative key
|
|
618
|
+
`beta.state` under the feature `alpha` produced the same absolute key, so one silently read
|
|
619
|
+
and overwrote the other's state. Scope segments are now escaped, and each is exactly one level.
|
|
620
|
+
|
|
621
|
+
## Real gym
|
|
622
|
+
|
|
623
|
+
`tests/real-gym/` runs the agent against live models instead of scripted ones. Each scenario
|
|
624
|
+
goes through an `AgentSystemLocal` collection, exactly the way the product assembles an agent: the agent
|
|
625
|
+
is created with its configuration — the real machine environment and the gym's own feature
|
|
626
|
+
config — and resolved from the collection, which runs one shared `FeatureGymHarness` that
|
|
627
|
+
contributes a real `record_answer` tool. So the assembled instructions, tool set, loop, and
|
|
628
|
+
durable records are all the product's own, and the credentials are the ones the installed Codex
|
|
629
|
+
and Claude Code assistants already manage, resolved in the same order Rig itself resolves them.
|
|
630
|
+
Nothing is mocked, so the suite runs only when it is asked for by name:
|
|
631
|
+
|
|
632
|
+
```sh
|
|
633
|
+
pnpm --filter @slopus/happy-agent-base test:real
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
The ordinary `test` script skips it. A machine that is not signed in to a vendor fails that
|
|
637
|
+
vendor's scenario with a message saying so rather than pretending the product is broken.
|
|
638
|
+
|
|
639
|
+
A `TracingProvider` wraps the real provider and records, at the wire boundary, the session's
|
|
640
|
+
system prompt and tools, every request's complete conversation, every streamed event with the
|
|
641
|
+
millisecond it arrived, the token counts, and any failure. After the run, every trace is
|
|
642
|
+
rendered into one self-contained page at `.context/real-gym/report.html`: a summary table of
|
|
643
|
+
each scenario's vendor, model, outcome, duration, inferences, tool calls, and tokens, then for
|
|
644
|
+
each scenario the agent's facts — its features, the model catalog it was offered, and its
|
|
645
|
+
environment — the assembled system prompt with the tools the model was shown, every inference's
|
|
646
|
+
reasoning, text, tool calls, conversation, and event stream, and finally the durable transcript
|
|
647
|
+
a restart would rebuild. The renderer has its own ordinary unit test, so the report stays
|
|
648
|
+
covered without spending tokens.
|
|
649
|
+
|
|
650
|
+
The live suite has already earned its keep twice: it exposed a provider stream left open after
|
|
651
|
+
`done`, and a lost message when a `send` landed while the agent was loading its history.
|