thoughtgear 0.1.0
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/LICENSE +21 -0
- package/PROMPT_HANDLER.md +689 -0
- package/README.md +207 -0
- package/dist/classes/PromptHandler.d.ts +263 -0
- package/dist/classes/PromptHandler.d.ts.map +1 -0
- package/dist/classes/PromptHandler.js +743 -0
- package/dist/classes/PromptHandler.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
# PromptHandler.ts — Deep Reference
|
|
2
|
+
|
|
3
|
+
A minimal, self-contained emulation of the openclaw agent loop (`src/agents/pi-embedded-runner`). The goal is to expose the **shape** of a real agent loop — stateless iterations, ORM-persisted transcript, pluggable Executor — in something you can read top-to-bottom in one sitting.
|
|
4
|
+
|
|
5
|
+
This document explains every type, class, method, and field in `PromptHandler.ts`, plus the reasoning behind each design choice.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Mental model
|
|
10
|
+
|
|
11
|
+
A user sends a prompt. The handler does **not** answer in one shot — it runs a **loop**:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
user message
|
|
15
|
+
│
|
|
16
|
+
▼
|
|
17
|
+
[iteration 0] build prompt ──▶ call model ──▶ stream blocks
|
|
18
|
+
│
|
|
19
|
+
┌─────────┴─────────┐
|
|
20
|
+
text only tool calls
|
|
21
|
+
│ │
|
|
22
|
+
DONE execute tools
|
|
23
|
+
│
|
|
24
|
+
▼
|
|
25
|
+
[iteration 1] build prompt
|
|
26
|
+
(now includes tool results)
|
|
27
|
+
──▶ call model ──▶ ...
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Each iteration is **stateless against memory**: it loads everything it needs from the ORM (history + run state). That property is what makes the loop runnable in two very different environments:
|
|
31
|
+
|
|
32
|
+
- **Locally**: one Node process, awaits each iteration in sequence.
|
|
33
|
+
- **AWS Lambda**: each iteration is a separate Lambda invocation. After saving state to the ORM, the current invocation calls `lambda.invoke(...)` on itself and returns. The next invocation picks up the `runId`, rebuilds context from the ORM, and continues.
|
|
34
|
+
|
|
35
|
+
The same `continueRun(runId)` method serves both modes. Only the **Executor** differs.
|
|
36
|
+
|
|
37
|
+
This is exactly the pattern openclaw uses in `pi-embedded-runner`: an outer retry/failover loop wrapping a stateless `runEmbeddedAttempt` that rebuilds the payload from history every iteration.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 2. File layout
|
|
42
|
+
|
|
43
|
+
The file is organized top-down so you can read it like a story:
|
|
44
|
+
|
|
45
|
+
1. **Types** — the data flowing through the system.
|
|
46
|
+
2. **ORM** — persistence surface (Mongo + SQL behind one façade).
|
|
47
|
+
3. **LLM provider** — abstraction over Anthropic / OpenAI / mock.
|
|
48
|
+
4. **Executor** — local vs Lambda iteration scheduling.
|
|
49
|
+
5. **PromptHandler** — the actual loop.
|
|
50
|
+
6. **Lambda entry point** — illustrative routing.
|
|
51
|
+
|
|
52
|
+
Each section depends only on what comes before it, so there are no forward references.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 3. Types
|
|
57
|
+
|
|
58
|
+
### `Tool`
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
type Tool = { key: string; description: string; content: string };
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Mirrors an openclaw **skill**: a chunk of instructions the model can pull into context.
|
|
65
|
+
|
|
66
|
+
- `key` — unique identifier, used as the function name when the model emits a `tool_call`.
|
|
67
|
+
- `description` — short text the model sees in the system prompt to decide whether to invoke it.
|
|
68
|
+
- `content` — the skill body. When the tool is invoked, this string becomes the `tool_result`. For function-style tools, `executeTool` is where you'd swap this for real dispatch (HTTP call, DB query, etc.).
|
|
69
|
+
|
|
70
|
+
The reason `content` is a `string` rather than a callback is that this scaffold treats tools as **skill definitions** — the model gets a body of instructions, not a typed function. Function-style tools (`(args) => result`) are easy to add later by extending the type.
|
|
71
|
+
|
|
72
|
+
### `ModelConfig`
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
type ModelConfig = { name; provider: 'anthropic' | 'openai' | 'google' | 'mock'; apiKey; baseUrl? };
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Everything the LLM layer needs to authenticate and select a backend. `provider` is the discriminant the factory uses to pick a concrete `LLMProvider`. `baseUrl` is optional because most users hit the vendor's default endpoint; it exists for proxies, self-hosted gateways, and local mock servers.
|
|
79
|
+
|
|
80
|
+
### `FileAttachment`
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
type FileAttachment = { name; mimeType; data: string /* base64 */ };
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A single attachment carried inline. Base64 is the lowest-common-denominator transport — works across HTTP, Lambda payloads, and DB rows without binary handling. For large files you'd swap this for an S3 URL/key, but the type stays the same shape.
|
|
87
|
+
|
|
88
|
+
### `ContentBlock`
|
|
89
|
+
|
|
90
|
+
A **discriminated union** of every kind of payload that can appear inside a message:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
| { type: 'text'; text }
|
|
94
|
+
| { type: 'reasoning'; text } // extended thinking
|
|
95
|
+
| { type: 'tool_call'; id; name; input } // model asks to call a tool
|
|
96
|
+
| { type: 'tool_result'; toolCallId; output; isError? } // our reply to a tool_call
|
|
97
|
+
| { type: 'file'; file: FileAttachment }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Discriminated unions are deliberate: every consumer narrows on `block.type`, so adding a new block type forces a compile error everywhere it's missed. This is the same shape Anthropic/OpenAI's SDKs use after normalization, which is why the LLM providers return `ContentBlock[]` directly rather than provider-native types.
|
|
101
|
+
|
|
102
|
+
`tool_call.input` is `unknown` because the model can emit arbitrary JSON; validation happens at the tool boundary (in `executeTool`), not at the type level.
|
|
103
|
+
|
|
104
|
+
### `Role` + `Message`
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
type Role = 'system' | 'user' | 'assistant' | 'tool';
|
|
108
|
+
|
|
109
|
+
type Message = {
|
|
110
|
+
id; runId; role; blocks: ContentBlock[]; createdAt; iteration: number;
|
|
111
|
+
};
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A `Message` is the atomic unit persisted in the ORM. Why these fields:
|
|
115
|
+
|
|
116
|
+
- `id` — stable identifier (UUID). Lets you de-duplicate or look up a single message without composite keys.
|
|
117
|
+
- `runId` — groups messages belonging to one logical "conversation turn." Indexed in the ORM for fast history retrieval.
|
|
118
|
+
- `role` — standard chat-completions roles. `'tool'` is its own role (rather than a sub-type of `assistant`/`user`) because most providers want tool results in a dedicated slot.
|
|
119
|
+
- `blocks` — array, not a single string, because a single message can contain text + tool calls + reasoning simultaneously.
|
|
120
|
+
- `createdAt` — for ordering and TTL/cleanup.
|
|
121
|
+
- `iteration` — which loop iteration produced this message. Useful for debugging ("why did the model loop 7 times?") and for compaction heuristics.
|
|
122
|
+
|
|
123
|
+
### `RunStatus` + `RunState`
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
type RunStatus = 'pending' | 'streaming' | 'awaiting_tools' | 'compacting' | 'done' | 'failed';
|
|
127
|
+
|
|
128
|
+
type RunState = { runId; status; iteration; lastStopReason?; lastError?; createdAt; updatedAt };
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`RunState` is the **only** mutable record per run. Each loop iteration reads it, mutates it, and writes it back. This is the file's central concurrency contract: you can resume a run from any process at any time using just `runId + ORM`.
|
|
132
|
+
|
|
133
|
+
The status values map to phases inside `continueRun`:
|
|
134
|
+
|
|
135
|
+
- `pending` — created or just finished a tool round; ready for the next model call.
|
|
136
|
+
- `streaming` — currently inside a model stream.
|
|
137
|
+
- `awaiting_tools` — model emitted tool_use; we're running tools.
|
|
138
|
+
- `compacting` — history was over the budget; summarizing.
|
|
139
|
+
- `done` — `stopReason === 'end_turn'`. Terminal.
|
|
140
|
+
- `failed` — unrecoverable error. Terminal.
|
|
141
|
+
|
|
142
|
+
A separate `iteration` counter exists because `status` alone can't gate `maxIterations` (you'd lose count across crashes).
|
|
143
|
+
|
|
144
|
+
`lastStopReason` and `lastError` are diagnostic. They're optional because they only have meaningful values in some states.
|
|
145
|
+
|
|
146
|
+
### `StreamCallbacks`
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
type StreamCallbacks = {
|
|
150
|
+
onPartialReply?, onReasoningStream?, onBlockReply?, onToolStart?, onToolResult?, onDone?
|
|
151
|
+
};
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The set of hooks the LLM layer and the loop fire as the run progresses. These are exactly the callbacks openclaw exposes upstream (`onPartialReply`, `onReasoningStream`, `onBlockReply`, `onToolStart`, `onToolResult`) and what channels like the Telegram plugin subscribe to in order to stream tokens back to users.
|
|
155
|
+
|
|
156
|
+
All callbacks are optional. If a consumer doesn't care about reasoning deltas, it just doesn't pass `onReasoningStream` — the field is `?`, so there's nothing to call.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 4. ORM layer
|
|
161
|
+
|
|
162
|
+
### `DbConfig`
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
type DbConfig =
|
|
166
|
+
| { type: 'mongodb'; uri; database }
|
|
167
|
+
| { type: 'sql'; dialect: 'postgres' | 'mysql' | 'sqlite'; uri };
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Another discriminated union. The `type` tag selects which adapter the `ORM` façade instantiates. SQL has a sub-discriminant (`dialect`) because driver and quoting differ across postgres/mysql/sqlite, even though the schema is identical.
|
|
171
|
+
|
|
172
|
+
### `OrmAdapter` interface
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
interface OrmAdapter {
|
|
176
|
+
saveMessage; getHistory;
|
|
177
|
+
saveRunState; getRunState;
|
|
178
|
+
cacheGet; cacheSet;
|
|
179
|
+
saveMemory; getMemory;
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Defines the **contract** that any backend must satisfy. Why these eight methods specifically:
|
|
184
|
+
|
|
185
|
+
- `saveMessage` / `getHistory` — write-once append log + read-many for one runId. The minimum required to rebuild a transcript each iteration.
|
|
186
|
+
- `saveRunState` / `getRunState` — upsert by runId. The mutable cursor that drives the loop.
|
|
187
|
+
- `cacheGet` / `cacheSet` — opaque KV with TTL. Reserved for prompt caching, model-call memoization, and any other transient deduplication. Real ORMs would back this with Redis, but the interface lets a single Mongo collection do it too.
|
|
188
|
+
- `saveMemory` / `getMemory` — scoped, long-lived KV. The `scope` parameter (e.g. `"user:123"`, `"channel:xyz"`) namespaces values so you can store per-user facts without colliding. Mirrors openclaw's `auto memory` system.
|
|
189
|
+
|
|
190
|
+
Eight methods is the **smallest** set that supports the rest of the file. Anything else (analytics, audit, vector search) belongs in a separate concern, not bolted on here.
|
|
191
|
+
|
|
192
|
+
### `ORM` class (façade)
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
class ORM implements OrmAdapter {
|
|
196
|
+
private adapter: OrmAdapter;
|
|
197
|
+
constructor(public config: DbConfig) {
|
|
198
|
+
this.adapter = config.type === 'mongodb'
|
|
199
|
+
? new MongoOrmAdapter(config)
|
|
200
|
+
: new SqlOrmAdapter(config);
|
|
201
|
+
}
|
|
202
|
+
saveMessage(msg) { return this.adapter.saveMessage(msg); }
|
|
203
|
+
// ...delegating each method through
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
This is the **Strategy pattern**. The façade picks an adapter once in the constructor, then forwards every call. Consumers depend on `ORM`, not on the concrete adapter — so swapping Mongo for SQL doesn't touch the `PromptHandler`.
|
|
208
|
+
|
|
209
|
+
Why a class delegating to an adapter, rather than just exporting the adapter directly? Two reasons:
|
|
210
|
+
|
|
211
|
+
1. **Stable import surface.** Callers always `new ORM(...)`. The internal split is invisible.
|
|
212
|
+
2. **Place for cross-cutting concerns.** If you later add tracing, retries, or metric hooks, they live on `ORM` and apply to both backends for free.
|
|
213
|
+
|
|
214
|
+
`config` is `public readonly` so consumers can inspect it (useful for logging the database target at startup) without being able to mutate it.
|
|
215
|
+
|
|
216
|
+
### `MongoOrmAdapter` and `SqlOrmAdapter`
|
|
217
|
+
|
|
218
|
+
Both implement `OrmAdapter` with **no-op stubs**. They exist to lock in the surface and let you compile and run the loop today with the `MockProvider`, then wire up real drivers (`mongodb`, `kysely`, `pg`, `better-sqlite3`) inside each method without touching the rest of the file.
|
|
219
|
+
|
|
220
|
+
The collection / table layout each is expected to use:
|
|
221
|
+
|
|
222
|
+
| Concept | Mongo collection | SQL table |
|
|
223
|
+
| ------------- | ---------------- | --------------- |
|
|
224
|
+
| `Message` | `messages` | `messages` |
|
|
225
|
+
| `RunState` | `run_states` | `run_states` |
|
|
226
|
+
| `cacheGet/Set`| `cache` | `cache` |
|
|
227
|
+
| `saveMemory/getMemory` | `memory` | `memory` |
|
|
228
|
+
|
|
229
|
+
Indexes that matter for performance:
|
|
230
|
+
|
|
231
|
+
- `messages.runId` + `messages.createdAt` — `getHistory` scans by runId, ordered.
|
|
232
|
+
- `run_states.runId` — primary key / unique index.
|
|
233
|
+
- `cache.key` — primary key; add a TTL index on `expiresAt` for Mongo, a cron sweep for SQL.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## 5. LLM provider layer
|
|
238
|
+
|
|
239
|
+
### `StreamResult`
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
type StreamResult = {
|
|
243
|
+
blocks: ContentBlock[];
|
|
244
|
+
stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | 'error';
|
|
245
|
+
};
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The provider-normalized return value of one model call. The loop only ever inspects these two fields:
|
|
249
|
+
|
|
250
|
+
- `blocks` — every piece of output the model produced (text, reasoning, tool calls). Persisted verbatim as the assistant message.
|
|
251
|
+
- `stopReason` — drives the loop's branching. `tool_use` → run tools and iterate. `end_turn` → finalize. Other values fail or extend depending on policy.
|
|
252
|
+
|
|
253
|
+
This is the same normalization openclaw does in `stream-resolution.ts` + per-provider wrappers (`openai-stream-wrappers.ts`, etc.).
|
|
254
|
+
|
|
255
|
+
### `LLMProvider` interface
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
interface LLMProvider {
|
|
259
|
+
stream(args: { system; messages; tools; callbacks?; runId }): Promise<StreamResult>;
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
A single method. Inputs are everything the model needs to produce a turn; output is the normalized `StreamResult`. The interface is `Promise<StreamResult>`, not an `AsyncIterable`, because **streaming behavior is communicated through callbacks** (`onPartialReply` etc.), and the final accumulated blocks are returned at the end. This shape is easier to compose and to test than an async iterator.
|
|
264
|
+
|
|
265
|
+
### `createLLMProvider` factory
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
function createLLMProvider(model: ModelConfig): LLMProvider {
|
|
269
|
+
switch (model.provider) {
|
|
270
|
+
case 'anthropic': return new AnthropicProvider(model);
|
|
271
|
+
case 'openai': return new OpenAIProvider(model);
|
|
272
|
+
case 'google': return new GoogleProvider(model);
|
|
273
|
+
case 'mock': return new MockProvider();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
A `switch` on the discriminant. TypeScript exhaustiveness-checks this — adding a new provider to `ModelConfig['provider']` without handling it here is a compile error.
|
|
279
|
+
|
|
280
|
+
### `AnthropicProvider`, `OpenAIProvider`, and `GoogleProvider`
|
|
281
|
+
|
|
282
|
+
Stubs. The body comment in each describes the integration:
|
|
283
|
+
|
|
284
|
+
- **Anthropic**: `@anthropic-ai/sdk` → `messages.stream(...)`. Map `content_block_delta` events to `onPartialReply` / `onReasoningStream`. Collect `tool_use` blocks into `ContentBlock` of type `'tool_call'`. Finalize on `message_stop`.
|
|
285
|
+
- **OpenAI**: equivalent shape with the `responses` API.
|
|
286
|
+
- **Google (Gemini)**: `@google/genai` → `ai.models.generateContentStream({...})`. The provider key is `'google'` to match openclaw's naming (`google-stream-wrappers.ts`, `google-prompt-cache.ts`); the underlying models are the Gemini family (`gemini-2.5-pro`, `gemini-2.5-flash`, etc.). A few Gemini-specific normalizations to keep in mind:
|
|
287
|
+
|
|
288
|
+
| Gemini concept | Normalized to |
|
|
289
|
+
| --------------------------------------------- | ------------------------------------------ |
|
|
290
|
+
| `systemInstruction` (separate from messages) | `args.system` — pass through, don't inline |
|
|
291
|
+
| `parts[].text` | `ContentBlock('text')` + `onPartialReply` |
|
|
292
|
+
| `parts[].thought` | `ContentBlock('reasoning')` + `onReasoningStream` |
|
|
293
|
+
| `parts[].functionCall { name, args }` | `ContentBlock('tool_call')` |
|
|
294
|
+
| Tool results → `parts[].functionResponse` under `role: 'user'` | reverse mapping when building `contents` |
|
|
295
|
+
| `finishReason: 'STOP' \| 'MAX_TOKENS' \| 'SAFETY' \| 'TOOL_USE'` | `StreamResult.stopReason` |
|
|
296
|
+
| `cachedContent` (implicit + explicit caching) | use ORM `cacheGet/Set` to track cache IDs |
|
|
297
|
+
|
|
298
|
+
All three return an empty text block today so the file compiles end-to-end.
|
|
299
|
+
|
|
300
|
+
### `MockProvider`
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
class MockProvider implements LLMProvider {
|
|
304
|
+
async stream(args) {
|
|
305
|
+
const turn = args.messages.filter((m) => m.role === 'assistant').length;
|
|
306
|
+
if (turn === 0 && args.tools[0]) {
|
|
307
|
+
// emit a tool call on turn 0
|
|
308
|
+
}
|
|
309
|
+
// text-only on subsequent turns
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
A **deterministic fake** for end-to-end testing without API keys. The rule "emit one tool call on iteration 0, then text on iteration 1+" is the simplest behavior that exercises every branch of the loop:
|
|
315
|
+
|
|
316
|
+
1. Iteration 0 → `stopReason: 'tool_use'` → loop runs the tool → schedules iteration 1.
|
|
317
|
+
2. Iteration 1 → `stopReason: 'end_turn'` → loop finalizes.
|
|
318
|
+
|
|
319
|
+
That's the complete state machine in two turns. If you can run `MockProvider` against your ORM and see two messages persisted, the rest of the system is working.
|
|
320
|
+
|
|
321
|
+
The mock also calls `onPartialReply` / `onBlockReply` so you can verify your callback wiring without a real provider.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## 6. Executor layer
|
|
326
|
+
|
|
327
|
+
This is the piece that **abstracts the runtime**. Local vs Lambda is the whole reason the file is split this way.
|
|
328
|
+
|
|
329
|
+
### `Executor` interface
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
interface Executor {
|
|
333
|
+
scheduleNextIteration(runId: string): Promise<void>;
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Exactly one method. "When the loop has more work to do, here's how you continue it." The implementation decides whether "continue" means "call the next function" or "fire a new Lambda invocation."
|
|
338
|
+
|
|
339
|
+
### `LocalExecutor`
|
|
340
|
+
|
|
341
|
+
```ts
|
|
342
|
+
class LocalExecutor implements Executor {
|
|
343
|
+
private handler!: PromptHandler;
|
|
344
|
+
bind(handler) { this.handler = handler; }
|
|
345
|
+
async scheduleNextIteration(runId) {
|
|
346
|
+
await new Promise((r) => setImmediate(r));
|
|
347
|
+
await this.handler.continueRun(runId);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
A few subtle things here:
|
|
353
|
+
|
|
354
|
+
- **`bind(handler)` instead of constructor injection.** There's a chicken-and-egg problem: `PromptHandler` wants to receive an `Executor`, and `LocalExecutor` wants a reference to the `PromptHandler` to call `continueRun`. The handler's constructor calls `executor.bind(this)` to close the loop. This avoids requiring callers to write `new LocalExecutor()` and then manually wire it.
|
|
355
|
+
- **`!` non-null assertion on `handler`.** A deliberate decision: `bind` is always called by the `PromptHandler` constructor before `scheduleNextIteration` is ever invoked, so the field is logically initialized. Marking it `!` keeps the type clean without optional-chaining noise everywhere.
|
|
356
|
+
- **`setImmediate` yield.** Yields to the event loop before recursing. Without this, a chain of synchronous-style iterations could starve the loop and trip Node's max recursion in pathological cases. Also gives `onDone` / `onToolResult` callbacks a chance to drain.
|
|
357
|
+
|
|
358
|
+
### `LambdaExecutor`
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
type LambdaInvoker = (payload: { runId; action: 'continue' }) => Promise<void>;
|
|
362
|
+
|
|
363
|
+
class LambdaExecutor implements Executor {
|
|
364
|
+
constructor(private invoke: LambdaInvoker) {}
|
|
365
|
+
async scheduleNextIteration(runId) {
|
|
366
|
+
await this.invoke({ runId, action: 'continue' });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
The invoke callback is **injected**, not imported. That's deliberate: this file doesn't depend on `@aws-sdk/client-lambda`. You build the invoker yourself:
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
|
|
375
|
+
const client = new LambdaClient({});
|
|
376
|
+
const invoke: LambdaInvoker = async (payload) => {
|
|
377
|
+
await client.send(new InvokeCommand({
|
|
378
|
+
FunctionName: 'my-thoughtgear',
|
|
379
|
+
InvocationType: 'Event', // async — fire and forget
|
|
380
|
+
Payload: Buffer.from(JSON.stringify(payload)),
|
|
381
|
+
}));
|
|
382
|
+
};
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
`InvocationType: 'Event'` is the important detail. It tells AWS not to wait for the new invocation to finish — the current invocation returns immediately, releasing its Lambda runtime container. Total billable time stays bounded per iteration regardless of how long the full run takes.
|
|
386
|
+
|
|
387
|
+
The reason iterating through Lambda invocations is interesting at all: a single Lambda has a hard 15-minute cap. A 10-step agent run with slow tool calls could exceed that. Splitting iterations across invocations turns one long run into many short ones.
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
391
|
+
## 7. PromptHandler — the loop
|
|
392
|
+
|
|
393
|
+
This is where everything plugs in.
|
|
394
|
+
|
|
395
|
+
### `PromptHandlerOptions`
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
type PromptHandlerOptions = {
|
|
399
|
+
context: string;
|
|
400
|
+
tools: Tool[];
|
|
401
|
+
model: ModelConfig;
|
|
402
|
+
orm: ORM;
|
|
403
|
+
executor?: Executor;
|
|
404
|
+
callbacks?: StreamCallbacks;
|
|
405
|
+
maxIterations?: number;
|
|
406
|
+
compactionCharThreshold?: number;
|
|
407
|
+
};
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
A single options object instead of a long positional argument list. Three are optional:
|
|
411
|
+
|
|
412
|
+
- `executor` — defaults to `LocalExecutor`. Pass `LambdaExecutor` for serverless.
|
|
413
|
+
- `callbacks` — defaults to `{}`. No-op for headless runs.
|
|
414
|
+
- `maxIterations` — defaults to `16`. Mirrors openclaw's retry-limit; prevents infinite tool loops.
|
|
415
|
+
- `compactionCharThreshold` — defaults to `80_000`. Past this, history gets summarized.
|
|
416
|
+
|
|
417
|
+
### Constructor
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
constructor(opts) {
|
|
421
|
+
// ...assign fields
|
|
422
|
+
this.llm = createLLMProvider(opts.model);
|
|
423
|
+
const executor = opts.executor ?? new LocalExecutor();
|
|
424
|
+
if (executor instanceof LocalExecutor) executor.bind(this);
|
|
425
|
+
this.executor = executor;
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Key moves:
|
|
430
|
+
|
|
431
|
+
1. **Eagerly build the LLM provider** so model errors surface at construction, not on first prompt.
|
|
432
|
+
2. **Default executor is `LocalExecutor`** — works out of the box without thinking about Lambda.
|
|
433
|
+
3. **`instanceof LocalExecutor` check** for the `bind` call. `LambdaExecutor` doesn't need a back-reference because it doesn't recurse into the handler.
|
|
434
|
+
|
|
435
|
+
### `handlePrompt({ text, files? })`
|
|
436
|
+
|
|
437
|
+
Entry point. Three steps:
|
|
438
|
+
|
|
439
|
+
1. **Generate `runId`** via `randomUUID()`. UUIDv4 from Node's `crypto` module — no external dep.
|
|
440
|
+
2. **Persist the user message** with `iteration: 0`. The blocks array is built from the text plus any file attachments.
|
|
441
|
+
3. **Persist initial `RunState`** with `status: 'pending'`, `iteration: 0`.
|
|
442
|
+
4. **Schedule the first iteration** via the Executor. Returns `{ runId }` immediately — the loop runs out-of-band.
|
|
443
|
+
|
|
444
|
+
The return value is `{ runId }`, not the final reply, because:
|
|
445
|
+
|
|
446
|
+
- In Lambda mode, the reply doesn't exist yet — it'll be produced across future invocations.
|
|
447
|
+
- In local mode, the caller can subscribe to `callbacks.onDone(runId)` for completion, or poll `orm.getHistory(runId)` / `orm.getRunState(runId)`.
|
|
448
|
+
|
|
449
|
+
This asymmetric API (fire-and-track-by-id) is the **only** API shape that works in both runtimes.
|
|
450
|
+
|
|
451
|
+
### `continueRun(runId)`
|
|
452
|
+
|
|
453
|
+
The body of the loop. Read it as a sequence of well-defined phases — same phases as `pi-embedded-runner/run/attempt.ts`:
|
|
454
|
+
|
|
455
|
+
```
|
|
456
|
+
1. Load state. Bail if terminal. Fail if over maxIterations.
|
|
457
|
+
2. Load history. Compact if oversized.
|
|
458
|
+
3. Build system prompt.
|
|
459
|
+
4. Mark status = 'streaming'.
|
|
460
|
+
5. Call llm.stream(...). Catch and fail on error.
|
|
461
|
+
6. Persist assistant message (whatever blocks came back).
|
|
462
|
+
7. If stopReason === 'tool_use':
|
|
463
|
+
a. Mark status = 'awaiting_tools'.
|
|
464
|
+
b. For each tool_call: execute, fire callbacks, persist tool_result message.
|
|
465
|
+
c. Bump iteration, mark status = 'pending'.
|
|
466
|
+
d. Schedule next iteration via executor.
|
|
467
|
+
e. Return.
|
|
468
|
+
Else:
|
|
469
|
+
Mark status = 'done', fire onDone.
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Why each phase exists:
|
|
473
|
+
|
|
474
|
+
- **Terminal-state check.** Prevents zombie iterations after a run was already cleaned up. Lambda re-invocations are idempotent against terminal states.
|
|
475
|
+
- **`maxIterations` guard.** Stops runaway tool loops (model keeps invoking the same tool). Hard-fails the run with a clear reason instead of looping forever.
|
|
476
|
+
- **Compaction before prompt build.** If history is over budget, the system prompt + history would blow past the model's context window. Compaction trims first.
|
|
477
|
+
- **`updateState({ status: 'streaming' })` before the model call.** If the process dies mid-stream, status alone tells you "this run was interrupted during a model call." Good for ops dashboards.
|
|
478
|
+
- **Single try/catch around `llm.stream`.** All model errors funnel into `fail(state, message)`. There's no partial recovery — that lives in the openclaw failover system, intentionally out of scope here.
|
|
479
|
+
- **Persist assistant message regardless of stop reason.** Even on `tool_use`, the assistant's reasoning and the tool calls themselves go into history. Otherwise the next iteration can't link tool results to their calls.
|
|
480
|
+
- **Sequential tool execution.** Tool calls run one at a time in iteration order. Parallel execution is easy to add (`Promise.all`) but means non-deterministic ordering and harder debugging.
|
|
481
|
+
- **Bump iteration + reset to `pending` before scheduling.** The next iteration sees a clean state. `awaiting_tools` is only the **transient** state during tool execution.
|
|
482
|
+
- **`return` immediately after `scheduleNextIteration`.** Critical for Lambda: the current invocation must end so a new one can take over. In local mode, the `LocalExecutor` resolves the `await` chain in the same process.
|
|
483
|
+
|
|
484
|
+
### `buildSystemPrompt()`
|
|
485
|
+
|
|
486
|
+
```ts
|
|
487
|
+
private buildSystemPrompt(): string {
|
|
488
|
+
const skills = this.tools
|
|
489
|
+
.map((t) => `## ${t.key}\n${t.description}\n\n${t.content}`)
|
|
490
|
+
.join('\n\n---\n\n');
|
|
491
|
+
return `${this.context}\n\n# Available Tools / Skills\n\n${skills}`;
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
Concatenates `context` + a "Tools" section. The format (`## key`, `---` separators) is conventional markdown that LLMs are well-trained on.
|
|
496
|
+
|
|
497
|
+
Why **not** pass tools as a structured `tools` parameter to the model API? You can, and that's how function-calling works at the API level. This file uses a hybrid: tools are advertised in the system prompt **and** passed to `llm.stream(...)` so the provider sees the schemas. The system-prompt copy is what lets skill-style tools work where the model just consumes the content rather than calling a function.
|
|
498
|
+
|
|
499
|
+
The order — context first, tools second — is deliberate for **prompt caching**. The context block is fixed across iterations (heartbeat, etc.), so providers can cache the prefix. Tools come second so changes to the tool list don't invalidate the context cache. This is the same ordering rule openclaw enforces in `prompt-cache-retention.ts`.
|
|
500
|
+
|
|
501
|
+
### `executeTool(call)`
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
private async executeTool(call) {
|
|
505
|
+
const tool = this.tools.find((t) => t.key === call.name);
|
|
506
|
+
if (!tool) return { type: 'tool_result', toolCallId: call.id, output: 'Error: ...', isError: true };
|
|
507
|
+
return { type: 'tool_result', toolCallId: call.id, output: tool.content };
|
|
508
|
+
}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
Two paths:
|
|
512
|
+
|
|
513
|
+
- **Unknown tool** → error result. The model occasionally hallucinates tool names; returning a clear error lets the next iteration self-correct.
|
|
514
|
+
- **Known tool** → return its `content` as the result.
|
|
515
|
+
|
|
516
|
+
The "return content as result" behavior is the **skill** interpretation of tools: the result is a body of instructions the next iteration should follow. For **function-style** tools, replace this line with a dispatch table:
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
const result = await toolHandlers[call.name]?.(call.input);
|
|
520
|
+
return { type: 'tool_result', toolCallId: call.id, output: JSON.stringify(result) };
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
The `isError` flag on `tool_result` exists because Anthropic's API supports it as a hint to the model. OpenAI silently ignores it.
|
|
524
|
+
|
|
525
|
+
### `maybeCompact(history, state)`
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
private async maybeCompact(history, state) {
|
|
529
|
+
const size = history.reduce((n, m) => n + JSON.stringify(m.blocks).length, 0);
|
|
530
|
+
if (size <= this.compactionCharThreshold) return history;
|
|
531
|
+
|
|
532
|
+
await this.updateState(state, { status: 'compacting' });
|
|
533
|
+
const keep = history.slice(-4);
|
|
534
|
+
const dropped = history.slice(0, -4);
|
|
535
|
+
const summary = { /* synthetic system message describing what was dropped */ };
|
|
536
|
+
await this.orm.saveMessage(summary);
|
|
537
|
+
return [summary, ...keep];
|
|
538
|
+
}
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
A simplified stand-in for openclaw's compaction (`compact.ts`, `compact.runtime.ts`).
|
|
542
|
+
|
|
543
|
+
- **Char-based budget.** Real implementations use token counts, but chars are a cheap proxy that needs no tokenizer.
|
|
544
|
+
- **Keep last 4 messages.** A common heuristic: recent context is always more valuable than older context. Number is arbitrary; tune per use case.
|
|
545
|
+
- **Synthetic summary system message.** Replaces dropped messages with a single placeholder. A real implementation would call the model to summarize the dropped content; this scaffold just notes "[compacted N messages]" so the model knows context was elided.
|
|
546
|
+
- **Status transition.** Sets `compacting` during the pass so an observer can see it. Status is left as `compacting` when we return; the caller (`continueRun`) overwrites it to `streaming` immediately after.
|
|
547
|
+
|
|
548
|
+
Why a synthetic message rather than mutating existing ones: the ORM is **append-only**. We never rewrite history — we extend it. Compaction adds a summary; the dropped messages still exist in the DB if you need them for forensics.
|
|
549
|
+
|
|
550
|
+
### `updateState(state, patch)` and `fail(state, error)`
|
|
551
|
+
|
|
552
|
+
```ts
|
|
553
|
+
private async updateState(state, patch) {
|
|
554
|
+
const next = { ...state, ...patch, updatedAt: new Date() };
|
|
555
|
+
Object.assign(state, next);
|
|
556
|
+
await this.orm.saveRunState(next);
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
A small utility. Three things happen:
|
|
561
|
+
|
|
562
|
+
1. **Spread + override** to produce the next state. Always bumps `updatedAt`.
|
|
563
|
+
2. **`Object.assign(state, next)`** mutates the caller's local reference so subsequent reads in the same iteration see the new state without another DB round-trip.
|
|
564
|
+
3. **Persist** to the ORM.
|
|
565
|
+
|
|
566
|
+
`fail` is a thin wrapper that sets `status: 'failed'` + `lastError`. It exists so error paths are syntactically identical and easy to grep.
|
|
567
|
+
|
|
568
|
+
---
|
|
569
|
+
|
|
570
|
+
## 8. Lambda entry point
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
type LambdaEvent =
|
|
574
|
+
| { action: 'start'; text; files? }
|
|
575
|
+
| { action: 'continue'; runId };
|
|
576
|
+
|
|
577
|
+
function makeLambdaHandler(handler: PromptHandler) {
|
|
578
|
+
return async (event) => {
|
|
579
|
+
if (event.action === 'start') return handler.handlePrompt({ text: event.text, files: event.files });
|
|
580
|
+
if (event.action === 'continue') { await handler.continueRun(event.runId); return { runId: event.runId }; }
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
The shape of a Lambda function that fronts the handler. Two routes:
|
|
586
|
+
|
|
587
|
+
- `start` — external trigger (API Gateway, EventBridge, SQS). Calls `handlePrompt`.
|
|
588
|
+
- `continue` — self-invocation from `LambdaExecutor`. Calls `continueRun`.
|
|
589
|
+
|
|
590
|
+
The discriminated `LambdaEvent` makes routing exhaustive at the type level — if you add a third action, TypeScript forces you to handle it.
|
|
591
|
+
|
|
592
|
+
This block is illustrative; you'd normally wire your own SQS/API Gateway adapter on top. The key insight is that **the same `PromptHandler` instance** handles both routes — no code duplication between "first turn" and "subsequent turns."
|
|
593
|
+
|
|
594
|
+
---
|
|
595
|
+
|
|
596
|
+
## 9. End-to-end example
|
|
597
|
+
|
|
598
|
+
```ts
|
|
599
|
+
import { PromptHandler, ORM, LocalExecutor } from './PromptHandler';
|
|
600
|
+
|
|
601
|
+
const orm = new ORM({ type: 'mongodb', uri: 'mongodb://localhost:27017', database: 'agent' });
|
|
602
|
+
|
|
603
|
+
const handler = new PromptHandler({
|
|
604
|
+
context: 'You are a helpful assistant. Current date: 2026-05-14.',
|
|
605
|
+
tools: [
|
|
606
|
+
{ key: 'search_docs', description: 'Search internal docs.', content: 'When invoked, search the knowledge base.' },
|
|
607
|
+
],
|
|
608
|
+
model: { name: 'claude-opus-4-7', provider: 'mock', apiKey: '' },
|
|
609
|
+
orm,
|
|
610
|
+
callbacks: {
|
|
611
|
+
onPartialReply: (chunk, runId) => console.log(`[${runId}]`, chunk),
|
|
612
|
+
onToolStart: (call, runId) => console.log(`[${runId}] tool:`, call.name),
|
|
613
|
+
onDone: (runId) => console.log(`[${runId}] done`),
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
const { runId } = await handler.handlePrompt({ text: 'What docs do we have on retention?' });
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
With `MockProvider`, this run produces:
|
|
621
|
+
|
|
622
|
+
1. User message persisted (iteration 0).
|
|
623
|
+
2. Assistant message with one `tool_call` for `search_docs` (iteration 0).
|
|
624
|
+
3. Tool result message containing the skill content (iteration 0).
|
|
625
|
+
4. Assistant text "Done." (iteration 1).
|
|
626
|
+
5. RunState → `done`.
|
|
627
|
+
|
|
628
|
+
Swap `provider: 'mock'` for `'anthropic'` + a real API key and the same flow drives a real model.
|
|
629
|
+
|
|
630
|
+
For Lambda:
|
|
631
|
+
|
|
632
|
+
```ts
|
|
633
|
+
const lambdaExecutor = new LambdaExecutor(invoke);
|
|
634
|
+
const handler = new PromptHandler({ /* ... */, executor: lambdaExecutor });
|
|
635
|
+
export const lambdaHandler = makeLambdaHandler(handler);
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
Same handler, same loop, just a different scheduling primitive.
|
|
639
|
+
|
|
640
|
+
---
|
|
641
|
+
|
|
642
|
+
## 10. Mapping to openclaw
|
|
643
|
+
|
|
644
|
+
For anyone cross-referencing this scaffold to the real `pi-embedded-runner`:
|
|
645
|
+
|
|
646
|
+
| Scaffold concept | openclaw equivalent |
|
|
647
|
+
| --------------------------- | ------------------------------------------------------------------- |
|
|
648
|
+
| `handlePrompt` | `runEmbeddedPiAgent` entry (`src/agents/pi-embedded-runner/run.ts:361`) |
|
|
649
|
+
| `continueRun` (one iter.) | `runEmbeddedAttempt` (`run/attempt.ts:802`) |
|
|
650
|
+
| `LLMProvider.stream` | `subscribeEmbeddedPiSession` + per-provider wrappers |
|
|
651
|
+
| `buildSystemPrompt` | `buildEmbeddedSystemPrompt` (`system-prompt.ts:17`) |
|
|
652
|
+
| `executeTool` + tool result | `runToolLifecycle` (`run/attempt.ts:2761`) |
|
|
653
|
+
| `maybeCompact` | `compact.ts` + `compaction-runtime-context.ts` |
|
|
654
|
+
| `RunState` | run-state.ts |
|
|
655
|
+
| `ORM` | session manager (`session-manager-*.ts`) |
|
|
656
|
+
| `Executor` | not split out — openclaw runs in-process, but the loop is shaped |
|
|
657
|
+
| | so it could be |
|
|
658
|
+
| `StreamCallbacks` | `onPartialReply` / `onReasoningStream` / `onBlockReply` etc. in |
|
|
659
|
+
| | `run/attempt.ts:2710-2718` |
|
|
660
|
+
| `maxIterations` | retry-limit (`run/retry-limit.ts`) |
|
|
661
|
+
| Prompt cache ordering rule | `prompt-cache-retention.ts` + cache-control payload wrappers |
|
|
662
|
+
|
|
663
|
+
The scaffold deliberately omits openclaw's:
|
|
664
|
+
|
|
665
|
+
- Failover / fallback (`run/assistant-failover.ts`, `run/failover-policy.ts`)
|
|
666
|
+
- Auth profile rotation (`run/auth-controller.ts`)
|
|
667
|
+
- Per-provider quirks (HTML-entity tool argument decoding, Google prompt cache, etc.)
|
|
668
|
+
- Idle-timeout breakers and abort signals
|
|
669
|
+
- Lane-based concurrency control
|
|
670
|
+
- Token-accurate accounting
|
|
671
|
+
|
|
672
|
+
Those exist because real production loops face real production failure modes. Add them when you hit those failure modes; don't add them speculatively.
|
|
673
|
+
|
|
674
|
+
---
|
|
675
|
+
|
|
676
|
+
## 11. What you'd change to harden this
|
|
677
|
+
|
|
678
|
+
Roughly in order of impact:
|
|
679
|
+
|
|
680
|
+
1. **Wire real ORM adapters.** Pick `mongodb` or `kysely`, fill in the eight methods, add the indexes listed above.
|
|
681
|
+
2. **Wire `AnthropicProvider.stream`** against `@anthropic-ai/sdk`. Map event types to blocks and callbacks. Same for `OpenAIProvider` (`openai` SDK) and `GoogleProvider` (`@google/genai`).
|
|
682
|
+
3. **Add an abort signal.** `handlePrompt` accepts an `AbortSignal`; pass it down to `llm.stream` and check it between iterations. Lets you cancel a runaway run.
|
|
683
|
+
4. **Add idle timeouts.** Wrap `llm.stream` in a `Promise.race` with a deadline so a stalled provider doesn't hold the Lambda forever.
|
|
684
|
+
5. **Real compaction.** Call the model itself to summarize the dropped messages; replace the placeholder summary with the model's output.
|
|
685
|
+
6. **Failover.** Catch model errors in `continueRun`, classify them, and either retry, swap model, or fail. This is the largest piece of openclaw not represented here.
|
|
686
|
+
7. **Telemetry.** Add a logger plumbed through callbacks; record token counts, tool latencies, iteration durations.
|
|
687
|
+
8. **Prompt-cache hits.** Use `cacheGet`/`cacheSet` to memoize the system prompt block hash per model — saves money on long-running loops with stable context.
|
|
688
|
+
|
|
689
|
+
Each of these is additive; none requires restructuring the loop.
|