@slopus/happy-agent-base 0.0.0 → 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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +640 -15
  3. package/dist/Agent.d.ts +86 -0
  4. package/dist/Agent.d.ts.map +1 -0
  5. package/dist/Agent.js +326 -0
  6. package/dist/Agent.js.map +1 -0
  7. package/dist/AgentBase.d.ts +340 -0
  8. package/dist/AgentBase.d.ts.map +1 -0
  9. package/dist/AgentBase.js +2295 -0
  10. package/dist/AgentBase.js.map +1 -0
  11. package/dist/AgentBaseHooks.d.ts +156 -0
  12. package/dist/AgentBaseHooks.d.ts.map +1 -0
  13. package/dist/AgentBaseHooks.js +2 -0
  14. package/dist/AgentBaseHooks.js.map +1 -0
  15. package/dist/AgentBasePending.d.ts +49 -0
  16. package/dist/AgentBasePending.d.ts.map +1 -0
  17. package/dist/AgentBasePending.js +73 -0
  18. package/dist/AgentBasePending.js.map +1 -0
  19. package/dist/AgentBaseState.d.ts +13 -0
  20. package/dist/AgentBaseState.d.ts.map +1 -0
  21. package/dist/AgentBaseState.js +2 -0
  22. package/dist/AgentBaseState.js.map +1 -0
  23. package/dist/AgentBaseStoreLock.d.ts +16 -0
  24. package/dist/AgentBaseStoreLock.d.ts.map +1 -0
  25. package/dist/AgentBaseStoreLock.js +37 -0
  26. package/dist/AgentBaseStoreLock.js.map +1 -0
  27. package/dist/AgentConfig.d.ts +67 -0
  28. package/dist/AgentConfig.d.ts.map +1 -0
  29. package/dist/AgentConfig.js +81 -0
  30. package/dist/AgentConfig.js.map +1 -0
  31. package/dist/AgentContexts.d.ts +40 -0
  32. package/dist/AgentContexts.d.ts.map +1 -0
  33. package/dist/AgentContexts.js +70 -0
  34. package/dist/AgentContexts.js.map +1 -0
  35. package/dist/AgentFeature.d.ts +119 -0
  36. package/dist/AgentFeature.d.ts.map +1 -0
  37. package/dist/AgentFeature.js +2 -0
  38. package/dist/AgentFeature.js.map +1 -0
  39. package/dist/AgentFeatureAction.d.ts +16 -0
  40. package/dist/AgentFeatureAction.d.ts.map +1 -0
  41. package/dist/AgentFeatureAction.js +2 -0
  42. package/dist/AgentFeatureAction.js.map +1 -0
  43. package/dist/AgentKV.d.ts +63 -0
  44. package/dist/AgentKV.d.ts.map +1 -0
  45. package/dist/AgentKV.js +122 -0
  46. package/dist/AgentKV.js.map +1 -0
  47. package/dist/AgentModel.d.ts +17 -0
  48. package/dist/AgentModel.d.ts.map +1 -0
  49. package/dist/AgentModel.js +2 -0
  50. package/dist/AgentModel.js.map +1 -0
  51. package/dist/AgentPersistence.d.ts +95 -0
  52. package/dist/AgentPersistence.d.ts.map +1 -0
  53. package/dist/AgentPersistence.js +2 -0
  54. package/dist/AgentPersistence.js.map +1 -0
  55. package/dist/AgentProviders.d.ts +22 -0
  56. package/dist/AgentProviders.d.ts.map +1 -0
  57. package/dist/AgentProviders.js +35 -0
  58. package/dist/AgentProviders.js.map +1 -0
  59. package/dist/AgentRef.d.ts +41 -0
  60. package/dist/AgentRef.d.ts.map +1 -0
  61. package/dist/AgentRef.js +60 -0
  62. package/dist/AgentRef.js.map +1 -0
  63. package/dist/AgentStorage.d.ts +19 -0
  64. package/dist/AgentStorage.d.ts.map +1 -0
  65. package/dist/AgentStorage.js +16 -0
  66. package/dist/AgentStorage.js.map +1 -0
  67. package/dist/AgentSystem.d.ts +50 -0
  68. package/dist/AgentSystem.d.ts.map +1 -0
  69. package/dist/AgentSystem.js +2 -0
  70. package/dist/AgentSystem.js.map +1 -0
  71. package/dist/AgentSystemContext.d.ts +15 -0
  72. package/dist/AgentSystemContext.d.ts.map +1 -0
  73. package/dist/AgentSystemContext.js +20 -0
  74. package/dist/AgentSystemContext.js.map +1 -0
  75. package/dist/AgentSystemLocal.d.ts +103 -0
  76. package/dist/AgentSystemLocal.d.ts.map +1 -0
  77. package/dist/AgentSystemLocal.js +282 -0
  78. package/dist/AgentSystemLocal.js.map +1 -0
  79. package/dist/AgentSystemRef.d.ts +56 -0
  80. package/dist/AgentSystemRef.d.ts.map +1 -0
  81. package/dist/AgentSystemRef.js +75 -0
  82. package/dist/AgentSystemRef.js.map +1 -0
  83. package/dist/AgentTaskContext.d.ts +17 -0
  84. package/dist/AgentTaskContext.d.ts.map +1 -0
  85. package/dist/AgentTaskContext.js +26 -0
  86. package/dist/AgentTaskContext.js.map +1 -0
  87. package/dist/AgentTool.d.ts +87 -0
  88. package/dist/AgentTool.d.ts.map +1 -0
  89. package/dist/AgentTool.js +5 -0
  90. package/dist/AgentTool.js.map +1 -0
  91. package/dist/index.d.ts +24 -1
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +31 -1
  94. package/dist/index.js.map +1 -1
  95. package/dist/models.d.ts +62 -0
  96. package/dist/models.d.ts.map +1 -0
  97. package/dist/models.js +185 -0
  98. package/dist/models.js.map +1 -0
  99. package/package.json +44 -37
package/README.md CHANGED
@@ -1,26 +1,651 @@
1
1
  # @slopus/happy-agent-base
2
2
 
3
- Shared foundations for Happy coding agents.
3
+ The minimal core of a Happy coding agent.
4
4
 
5
- The package is bootstrapped with an empty public API. Future agent-base behavior belongs in
6
- `sources` and is exported through `sources/index.ts`.
5
+ `AgentBase` is one agent session over one `@slopus/happy-providers` provider. Messages arrive
6
+ through two FIFO queues — steering and send the loop replays the full conversation to the
7
+ provider session, and the streamed events are forwarded to the optional hooks while the
8
+ assistant reply joins the history. When the model stops for tool calls, the agent executes them
9
+ and feeds the results back. The conversation is observable only through hooks; there is no
10
+ external transcript or status surface.
7
11
 
8
- ```text
9
- sources/index.ts
10
- |
11
- v
12
- dist/index.js
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
+
23
+ ## Core API
24
+
25
+ ```ts
26
+ class AgentBase {
27
+ constructor(ctx: Context, options: AgentBaseOptions);
28
+
29
+ readonly id: string;
30
+ readonly state: AgentBaseState; // the agent's own copy, mutable directly
31
+
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>;
42
+ start(): void;
43
+ abort(ctx: Context, options?: AgentBaseAwaitOptions): Promise<void>;
44
+ compact(ctx: Context, options?: AgentBaseAwaitOptions): Promise<void>;
45
+ waitForIdle(): Promise<void>;
46
+ close(): Promise<void>;
47
+ }
48
+
49
+ interface AgentBaseAwaitOptions {
50
+ await?: boolean; // default false: return once the agent has taken the request on
51
+ }
52
+
53
+ interface AgentBaseOptions {
54
+ id: string;
55
+ providers: AgentProviders;
56
+ provider: string; // registry ID; serializable alongside model and effort
57
+ persistence: AgentBasePersistence;
58
+ hooks?: AgentBaseHooks;
59
+ initialState?: Partial<AgentBaseState>; // copied into the agent's own state
60
+ model?: string;
61
+ effort?: SessionReasoningEffort;
62
+ serviceTier?: SessionServiceTier;
63
+ steeringMode?: "one-at-a-time" | "all"; // default "one-at-a-time"
64
+ sendMode?: "one-at-a-time" | "all"; // default "one-at-a-time"
65
+ }
66
+
67
+ interface AgentBaseState {
68
+ instructions: string;
69
+ tools: AnyAgentTool[];
70
+ }
71
+
72
+ interface AgentBaseHooks {
73
+ onEvent?: (ctx: Context, event: SessionEvent) => void;
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>;
84
+ beforeAgentLoop?: (ctx: Context) => void;
85
+ beforeTurn?: (
86
+ ctx: Context,
87
+ turn: AgentBaseTurnStart,
88
+ ) => MaybePromise<readonly AgentFeatureAction[] | undefined>;
89
+ beforeInference?: (ctx: Context) => void;
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
162
+ }
163
+
164
+ interface AgentBaseModelChange {
165
+ previousModel: string | undefined;
166
+ model: string;
167
+ previousProvider: string;
168
+ provider: string;
169
+ providers: AgentProviders;
170
+ previousProviderInstance: BaseProvider | null;
171
+ providerInstance: BaseProvider | null;
172
+ wasReset: boolean; // the change was incompatible and the history was erased
173
+ }
174
+
175
+ interface AgentBaseMessageOptions {
176
+ provider?: string; // registry ID to switch to
177
+ model?: string;
178
+ effort?: SessionReasoningEffort;
179
+ serviceTier?: SessionServiceTier;
180
+ }
181
+
182
+ type AgentFeatureAction =
183
+ | { type: "steer"; message: SessionUserMessage }
184
+ | { type: "send"; message: SessionUserMessage }
185
+ | { type: "compact" };
186
+
187
+ interface AgentTool<Args extends TSchema = TSchema, Result extends TSchema = TSchema> {
188
+ // The provider-facing descriptor fields of SessionTool, with parameters typed as Args, plus:
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>;
198
+ returnType: Result;
199
+ execute(ctx: Context, args: Static<Args>): Promise<Static<Result>>;
200
+ toLLM(result: Static<Result>): readonly SessionOutputBlock[];
201
+ isError?(result: Static<Result>): boolean;
202
+ }
203
+
204
+ function defineAgentTool<const Args extends TSchema, const Result extends TSchema>(
205
+ tool: AgentTool<Args, Result>,
206
+ ): AgentTool<Args, Result>;
207
+
208
+ interface AgentBasePersistence {
209
+ transaction<Result>(ctx: Context, work: (ctx: Context) => Promise<Result>): Promise<Result>;
210
+ load(ctx: Context): Promise<readonly AgentBaseRecord[]>;
211
+ append(ctx: Context, record: AgentBaseRecord): Promise<void>;
212
+ clearRecords(ctx: Context): Promise<void>; // physical delete, used inside the compaction transaction
213
+ readValues(ctx: Context, prefix: string): Promise<readonly { key: string; value: unknown }[]>;
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>;
222
+ deleteValue(ctx: Context, key: string): Promise<void>;
223
+ deleteValueIfPresent(ctx: Context, key: string): Promise<boolean>; // claim
224
+ }
13
225
  ```
14
226
 
15
- ## Releasing
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
+
231
+ Persistence is an append-only main context store plus a sorted key-value store alongside it. The
232
+ agent serializes every operation through one internal lock (configured to crash on re-entry), so
233
+ implementations never see concurrent calls and need no locking of their own.
234
+
235
+ A queued message is written under a `steering.` or `send.` key ordered by append time;
236
+ nothing that is not yet part of the context reaches the main store. When a queue drains, each
237
+ consumed message is appended as a user record and its queue key deleted, all inside one
238
+ `transaction` so a crash or failure can never leave a message in both stores or neither, and
239
+ only then does inference run on the resulting context. Transactions are completely transparent
240
+ to the agent: the implementation opens one, hands work a derived context its own operations
241
+ recognize, and carries the transaction on that context however it likes; work resolving
242
+ commits, a thrown error rolls back. Assistant output is appended one finished block at a time as
243
+ it streams, so main-store records always arrive in context order and consecutive block records
244
+ reassemble into one assistant message on load. The agent loop loads everything once, on the first
245
+ inference attempt; the load result replaces the in-memory state, including leftover queued
246
+ messages from an earlier process, which join the next turn. A failed load is reported as an
247
+ `internal_error` done event and is not sticky: the next requested turn retries it, with every
248
+ queued message still safely waiting.
249
+
250
+ The two queues give four delivery strategies, mirroring Pi:
251
+
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. |
258
+
259
+ Both modes default to `"one-at-a-time"`, and steering always takes precedence over sent messages.
260
+ Queue consumption happens only between inferences — never mid-stream and never during a tool
261
+ batch — so an injected message can never interleave with an active response's block records.
262
+
263
+ `steer` and `send` resolve once the durable queue write lands, so a failed write keeps the
264
+ message out of the conversation; they wait neither for the history load nor for the turn. Each
265
+ message may carry its own inference settings — provider, model, effort, and service tier —
266
+ which take effect when the message is consumed and stay effective for every later message that does not
267
+ override them, surviving restarts through a durable settings entry. A message without settings
268
+ uses the previously effective values, or the constructor defaults when nothing was ever carried;
269
+ relying on those defaults is discouraged — prefer sending settings with the message.
270
+
271
+ A provider or model change is checked against the provider-model compatibility matrix from
272
+ `@slopus/happy-providers`, using the compatibility types the providers were registered with. A
273
+ compatible change keeps the conversation; a compatible provider change still gets a fresh
274
+ session on the new provider, since a session is bound to the provider that created it. An
275
+ incompatible change — including a switch to a different provider of the same type, or to an
276
+ unregistered ID — resets the conversation: the durable history is erased completely, the old
277
+ provider session is destroyed, and a fresh session serves the new selection. The `modelChanged`
278
+ hook fires on every selection change with the old and new model, both provider IDs and live
279
+ instances, the registry, and the `wasReset` flag; on a reset the handoff system message it
280
+ returns is injected at the very beginning of the fresh context — without one the context starts
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
284
+ thrown provider or load failure is reported to the `onEvent` hook as an `internal_error` done
285
+ event instead of rejecting the loop. The agent never retries inference itself — providers own
286
+ retry semantics and surface them as `retrying` events. A provider-reported error response ends
287
+ that response but not the turn: messages still queued drain into a fresh inference, each drain
288
+ consuming from a finite queue, so a persistently failing provider cannot loop.
289
+
290
+ A turn that ends failed surfaces its error to the context as a durable system message
291
+ (`The last turn failed: <message>`), so the next inference sees what went wrong. Only
292
+ unrecovered failures leave this trace: a provider-reported error followed by a successful
293
+ response in the same turn recovers silently, and a failed history load appends nothing since
294
+ there is no loaded context to append to.
16
295
 
17
- From a clean `main` worktree whose `HEAD` matches `origin/main`, run:
296
+ When a turn stops for tool calls, every call in the batch runs in parallel. Arguments are
297
+ validated against the tool's TypeBox `parameters` schema before `execute` runs, so `execute`
298
+ receives them as `Static<Args>` rather than unknown. `execute` returns a structured result that
299
+ is validated against `returnType` and then rendered into output blocks for the model with
300
+ `toLLM`; an optional `isError` predicate marks a structured result as an error. A missing tool,
301
+ invalid JSON arguments, arguments that fail the schema, an incomplete call, a thrown `execute`,
302
+ or a result that fails `returnType` becomes an error tool result
303
+ (`isError: true`) for the model instead of failing the run; provider-settled server calls are
304
+ never executed by the agent, and their streamed `toolcall_result_*` events are simply ignored —
305
+ the server call block stays in the history, the events reach the hooks, and no tool result
306
+ message is stored or owed.
307
+
308
+ Before any call in a batch executes, the whole batch is committed to the sorted store under
309
+ `tool.` keys ordered by position, so a crash mid-batch leaves a durable record of the calls still
310
+ owed a result. Calls run in parallel, but results land strictly in call order: a finished result
311
+ waits until every earlier call in the batch has committed, and each commit appends the `tool`
312
+ record and deletes the pending entry in one transaction. Once the batch is complete the loop runs
313
+ inference again with the full context.
314
+
315
+ `start` begins the loop without a new message: it loads the durable state and continues a turn
316
+ that was cut off by a crash — leftover queued messages are consumed, a dispatched `tool.`
317
+ batch is settled, and an unanswered user or tool message gets its inference. When an interrupted
318
+ batch resumes, only tools marked `durable: true` execute again; every other interrupted call
319
+ becomes an error tool result, since the agent cannot know whether its side effects already
320
+ happened. On an idle history `start` loads and does nothing more.
321
+
322
+ `compact` compacts the conversation through the provider session. It waits for the active turn
323
+ to end — including queued messages already draining — or runs right away when idle, snapshots
324
+ the history, and asks the provider to compact it. The completed replacement context supersedes
325
+ the compacted history while any message that joined after the snapshot is kept. In one atomic
326
+ transaction the superseded records are physically deleted and the replacement — the messages
327
+ that stay — is appended as a `compaction` record, which then opens the store while later
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.
332
+
333
+ Hooks receive the agent's context first. That context — shared by tool executions — is derived
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.
359
+
360
+ The lifecycle hooks bracket the loop's own structure. `beforeAgentLoop` fires when the loop
361
+ leaves the settled state and begins working, and `afterAgentLoop` fires when it would settle
362
+ back to idle; between them, each turn is bracketed by `beforeTurn` and `afterTurn`, and each
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 —
371
+ never fails the run.
372
+
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.
386
+ The inference stream is abandoned and asked to close, a `done` event with state `cancelled` is
387
+ emitted, blocks that already finished stay in the history while an unfinished block is dropped
388
+ everywhere, and each still-running tool call settles as an error tool result saying it was
389
+ aborted — consuming its pending `tool.` entry so the batch leaves a complete context behind.
390
+ The queued turn request is dropped too, but messages still waiting in the steering and
391
+ send queues stay durable and join the next requested turn.
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
+
473
+ `AgentProviders` is a mutable registry of provider instances keyed by caller-supplied IDs, so the
474
+ same provider class can be registered under several IDs. `add(id, provider, type)` registers an
475
+ instance together with its compatibility type (`"claude"`, `"codex"`, `"grok"`, `"bedrock"`, or
476
+ `"gym"`), `get(id)` returns the provider or null, and `typeOf(id)` returns the registered type
477
+ or null. The agent is configured entirely with serializable values — a
478
+ provider registry ID, a model name, an effort level, and a service tier — and resolves the live provider from
479
+ the registry when the session is first created; an ID that is not registered at that moment
480
+ fails the turn like any thrown error.
481
+
482
+ ## Validation
483
+
484
+ ```sh
485
+ pnpm --filter @slopus/happy-agent-base check
486
+ pnpm --filter @slopus/happy-agent-base test
487
+ pnpm --filter @slopus/happy-agent-base build
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:
18
631
 
19
632
  ```sh
20
- pnpm release:happy-agent-base:patch
633
+ pnpm --filter @slopus/happy-agent-base test:real
21
634
  ```
22
635
 
23
- The local release validates, tests, and builds only `@slopus/happy-agent-base`, then creates and
24
- pushes a `happy-agent-base-vX.Y.Z` tag. The shared GitHub publish workflow again validates, tests,
25
- and builds only this package before publishing it through npm trusted publishing. The npm trusted
26
- publisher must identify `slopus/rig`, `publish.yml`, and the `npm` GitHub environment.
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.