@lenylvt/pi-agent-core 0.64.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/src/agent.ts ADDED
@@ -0,0 +1,618 @@
1
+ /**
2
+ * Agent class that uses the agent-loop directly.
3
+ * No transport abstraction - calls streamSimple via the loop.
4
+ */
5
+
6
+ import {
7
+ getModel,
8
+ type ImageContent,
9
+ type Message,
10
+ type Model,
11
+ type SimpleStreamOptions,
12
+ streamSimple,
13
+ type TextContent,
14
+ type ThinkingBudgets,
15
+ type Transport,
16
+ } from "@lenylvt/pi-ai";
17
+ import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js";
18
+ import type {
19
+ AfterToolCallContext,
20
+ AfterToolCallResult,
21
+ AgentContext,
22
+ AgentEvent,
23
+ AgentLoopConfig,
24
+ AgentMessage,
25
+ AgentState,
26
+ AgentTool,
27
+ BeforeToolCallContext,
28
+ BeforeToolCallResult,
29
+ StreamFn,
30
+ ThinkingLevel,
31
+ ToolExecutionMode,
32
+ } from "./types.js";
33
+
34
+ /**
35
+ * Default convertToLlm: Keep only LLM-compatible messages, convert attachments.
36
+ */
37
+ function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
38
+ return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult");
39
+ }
40
+
41
+ export interface AgentOptions {
42
+ initialState?: Partial<AgentState>;
43
+
44
+ /**
45
+ * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
46
+ * Default filters to user/assistant/toolResult and converts attachments.
47
+ */
48
+ convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
49
+
50
+ /**
51
+ * Optional transform applied to context before convertToLlm.
52
+ * Use for context pruning, injecting external context, etc.
53
+ */
54
+ transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
55
+
56
+ /**
57
+ * Steering mode: "all" = send all steering messages at once, "one-at-a-time" = one per turn
58
+ */
59
+ steeringMode?: "all" | "one-at-a-time";
60
+
61
+ /**
62
+ * Follow-up mode: "all" = send all follow-up messages at once, "one-at-a-time" = one per turn
63
+ */
64
+ followUpMode?: "all" | "one-at-a-time";
65
+
66
+ /**
67
+ * Custom stream function (for proxy backends, etc.). Default uses streamSimple.
68
+ */
69
+ streamFn?: StreamFn;
70
+
71
+ /**
72
+ * Optional session identifier forwarded to LLM providers.
73
+ * Used by providers that support session-based caching (e.g., OpenAI Codex).
74
+ */
75
+ sessionId?: string;
76
+
77
+ /**
78
+ * Resolves an API key dynamically for each LLM call.
79
+ * Useful for expiring tokens (e.g., GitHub Copilot OAuth).
80
+ */
81
+ getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
82
+
83
+ /**
84
+ * Inspect or replace provider payloads before they are sent.
85
+ */
86
+ onPayload?: SimpleStreamOptions["onPayload"];
87
+
88
+ /**
89
+ * Custom token budgets for thinking levels (token-based providers only).
90
+ */
91
+ thinkingBudgets?: ThinkingBudgets;
92
+
93
+ /**
94
+ * Preferred transport for providers that support multiple transports.
95
+ */
96
+ transport?: Transport;
97
+
98
+ /**
99
+ * Maximum delay in milliseconds to wait for a retry when the server requests a long wait.
100
+ * If the server's requested delay exceeds this value, the request fails immediately,
101
+ * allowing higher-level retry logic to handle it with user visibility.
102
+ * Default: 60000 (60 seconds). Set to 0 to disable the cap.
103
+ */
104
+ maxRetryDelayMs?: number;
105
+
106
+ /** Tool execution mode. Default: "parallel" */
107
+ toolExecution?: ToolExecutionMode;
108
+
109
+ /** Called before a tool is executed, after arguments have been validated. */
110
+ beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;
111
+
112
+ /** Called after a tool finishes executing, before final tool events are emitted. */
113
+ afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;
114
+ }
115
+
116
+ export class Agent {
117
+ private _state: AgentState = {
118
+ systemPrompt: "",
119
+ model: getModel("anthropic", "claude-sonnet-4-6"),
120
+ thinkingLevel: "off",
121
+ tools: [],
122
+ messages: [],
123
+ isStreaming: false,
124
+ streamMessage: null,
125
+ pendingToolCalls: new Set<string>(),
126
+ error: undefined,
127
+ };
128
+
129
+ private listeners = new Set<(e: AgentEvent) => void>();
130
+ private abortController?: AbortController;
131
+ private convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
132
+ private transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
133
+ private steeringQueue: AgentMessage[] = [];
134
+ private followUpQueue: AgentMessage[] = [];
135
+ private steeringMode: "all" | "one-at-a-time";
136
+ private followUpMode: "all" | "one-at-a-time";
137
+ public streamFn: StreamFn;
138
+ private _sessionId?: string;
139
+ public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
140
+ private _onPayload?: SimpleStreamOptions["onPayload"];
141
+ private runningPrompt?: Promise<void>;
142
+ private resolveRunningPrompt?: () => void;
143
+ private _thinkingBudgets?: ThinkingBudgets;
144
+ private _transport: Transport;
145
+ private _maxRetryDelayMs?: number;
146
+ private _toolExecution: ToolExecutionMode;
147
+ private _beforeToolCall?: (
148
+ context: BeforeToolCallContext,
149
+ signal?: AbortSignal,
150
+ ) => Promise<BeforeToolCallResult | undefined>;
151
+ private _afterToolCall?: (
152
+ context: AfterToolCallContext,
153
+ signal?: AbortSignal,
154
+ ) => Promise<AfterToolCallResult | undefined>;
155
+
156
+ constructor(opts: AgentOptions = {}) {
157
+ this._state = { ...this._state, ...opts.initialState };
158
+ this.convertToLlm = opts.convertToLlm || defaultConvertToLlm;
159
+ this.transformContext = opts.transformContext;
160
+ this.steeringMode = opts.steeringMode || "one-at-a-time";
161
+ this.followUpMode = opts.followUpMode || "one-at-a-time";
162
+ this.streamFn = opts.streamFn || streamSimple;
163
+ this._sessionId = opts.sessionId;
164
+ this.getApiKey = opts.getApiKey;
165
+ this._onPayload = opts.onPayload;
166
+ this._thinkingBudgets = opts.thinkingBudgets;
167
+ this._transport = opts.transport ?? "sse";
168
+ this._maxRetryDelayMs = opts.maxRetryDelayMs;
169
+ this._toolExecution = opts.toolExecution ?? "parallel";
170
+ this._beforeToolCall = opts.beforeToolCall;
171
+ this._afterToolCall = opts.afterToolCall;
172
+ }
173
+
174
+ /**
175
+ * Get the current session ID used for provider caching.
176
+ */
177
+ get sessionId(): string | undefined {
178
+ return this._sessionId;
179
+ }
180
+
181
+ /**
182
+ * Set the session ID for provider caching.
183
+ * Call this when switching sessions (new session, branch, resume).
184
+ */
185
+ set sessionId(value: string | undefined) {
186
+ this._sessionId = value;
187
+ }
188
+
189
+ /**
190
+ * Get the current thinking budgets.
191
+ */
192
+ get thinkingBudgets(): ThinkingBudgets | undefined {
193
+ return this._thinkingBudgets;
194
+ }
195
+
196
+ /**
197
+ * Set custom thinking budgets for token-based providers.
198
+ */
199
+ set thinkingBudgets(value: ThinkingBudgets | undefined) {
200
+ this._thinkingBudgets = value;
201
+ }
202
+
203
+ /**
204
+ * Get the current preferred transport.
205
+ */
206
+ get transport(): Transport {
207
+ return this._transport;
208
+ }
209
+
210
+ /**
211
+ * Set the preferred transport.
212
+ */
213
+ setTransport(value: Transport) {
214
+ this._transport = value;
215
+ }
216
+
217
+ /**
218
+ * Get the current max retry delay in milliseconds.
219
+ */
220
+ get maxRetryDelayMs(): number | undefined {
221
+ return this._maxRetryDelayMs;
222
+ }
223
+
224
+ /**
225
+ * Set the maximum delay to wait for server-requested retries.
226
+ * Set to 0 to disable the cap.
227
+ */
228
+ set maxRetryDelayMs(value: number | undefined) {
229
+ this._maxRetryDelayMs = value;
230
+ }
231
+
232
+ get toolExecution(): ToolExecutionMode {
233
+ return this._toolExecution;
234
+ }
235
+
236
+ setToolExecution(value: ToolExecutionMode) {
237
+ this._toolExecution = value;
238
+ }
239
+
240
+ setBeforeToolCall(
241
+ value:
242
+ | ((context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>)
243
+ | undefined,
244
+ ) {
245
+ this._beforeToolCall = value;
246
+ }
247
+
248
+ setAfterToolCall(
249
+ value:
250
+ | ((context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>)
251
+ | undefined,
252
+ ) {
253
+ this._afterToolCall = value;
254
+ }
255
+
256
+ get state(): AgentState {
257
+ return this._state;
258
+ }
259
+
260
+ subscribe(fn: (e: AgentEvent) => void): () => void {
261
+ this.listeners.add(fn);
262
+ return () => this.listeners.delete(fn);
263
+ }
264
+
265
+ // State mutators
266
+ setSystemPrompt(v: string) {
267
+ this._state.systemPrompt = v;
268
+ }
269
+
270
+ setModel(m: Model<any>) {
271
+ this._state.model = m;
272
+ }
273
+
274
+ setThinkingLevel(l: ThinkingLevel) {
275
+ this._state.thinkingLevel = l;
276
+ }
277
+
278
+ setSteeringMode(mode: "all" | "one-at-a-time") {
279
+ this.steeringMode = mode;
280
+ }
281
+
282
+ getSteeringMode(): "all" | "one-at-a-time" {
283
+ return this.steeringMode;
284
+ }
285
+
286
+ setFollowUpMode(mode: "all" | "one-at-a-time") {
287
+ this.followUpMode = mode;
288
+ }
289
+
290
+ getFollowUpMode(): "all" | "one-at-a-time" {
291
+ return this.followUpMode;
292
+ }
293
+
294
+ setTools(t: AgentTool<any>[]) {
295
+ this._state.tools = t;
296
+ }
297
+
298
+ replaceMessages(ms: AgentMessage[]) {
299
+ this._state.messages = ms.slice();
300
+ }
301
+
302
+ appendMessage(m: AgentMessage) {
303
+ this._state.messages = [...this._state.messages, m];
304
+ }
305
+
306
+ /**
307
+ * Queue a steering message while the agent is running.
308
+ * Delivered after the current assistant turn finishes executing its tool calls,
309
+ * before the next LLM call.
310
+ */
311
+ steer(m: AgentMessage) {
312
+ this.steeringQueue.push(m);
313
+ }
314
+
315
+ /**
316
+ * Queue a follow-up message to be processed after the agent finishes.
317
+ * Delivered only when agent has no more tool calls or steering messages.
318
+ */
319
+ followUp(m: AgentMessage) {
320
+ this.followUpQueue.push(m);
321
+ }
322
+
323
+ clearSteeringQueue() {
324
+ this.steeringQueue = [];
325
+ }
326
+
327
+ clearFollowUpQueue() {
328
+ this.followUpQueue = [];
329
+ }
330
+
331
+ clearAllQueues() {
332
+ this.steeringQueue = [];
333
+ this.followUpQueue = [];
334
+ }
335
+
336
+ hasQueuedMessages(): boolean {
337
+ return this.steeringQueue.length > 0 || this.followUpQueue.length > 0;
338
+ }
339
+
340
+ private dequeueSteeringMessages(): AgentMessage[] {
341
+ if (this.steeringMode === "one-at-a-time") {
342
+ if (this.steeringQueue.length > 0) {
343
+ const first = this.steeringQueue[0];
344
+ this.steeringQueue = this.steeringQueue.slice(1);
345
+ return [first];
346
+ }
347
+ return [];
348
+ }
349
+
350
+ const steering = this.steeringQueue.slice();
351
+ this.steeringQueue = [];
352
+ return steering;
353
+ }
354
+
355
+ private dequeueFollowUpMessages(): AgentMessage[] {
356
+ if (this.followUpMode === "one-at-a-time") {
357
+ if (this.followUpQueue.length > 0) {
358
+ const first = this.followUpQueue[0];
359
+ this.followUpQueue = this.followUpQueue.slice(1);
360
+ return [first];
361
+ }
362
+ return [];
363
+ }
364
+
365
+ const followUp = this.followUpQueue.slice();
366
+ this.followUpQueue = [];
367
+ return followUp;
368
+ }
369
+
370
+ clearMessages() {
371
+ this._state.messages = [];
372
+ }
373
+
374
+ /** The current abort signal, or undefined when the agent is not streaming. */
375
+ get signal(): AbortSignal | undefined {
376
+ return this.abortController?.signal;
377
+ }
378
+
379
+ abort() {
380
+ this.abortController?.abort();
381
+ }
382
+
383
+ waitForIdle(): Promise<void> {
384
+ return this.runningPrompt ?? Promise.resolve();
385
+ }
386
+
387
+ reset() {
388
+ this._state.messages = [];
389
+ this._state.isStreaming = false;
390
+ this._state.streamMessage = null;
391
+ this._state.pendingToolCalls = new Set<string>();
392
+ this._state.error = undefined;
393
+ this.steeringQueue = [];
394
+ this.followUpQueue = [];
395
+ }
396
+
397
+ /** Send a prompt with an AgentMessage */
398
+ async prompt(message: AgentMessage | AgentMessage[]): Promise<void>;
399
+ async prompt(input: string, images?: ImageContent[]): Promise<void>;
400
+ async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]) {
401
+ if (this._state.isStreaming) {
402
+ throw new Error(
403
+ "Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.",
404
+ );
405
+ }
406
+
407
+ const model = this._state.model;
408
+ if (!model) throw new Error("No model configured");
409
+
410
+ let msgs: AgentMessage[];
411
+
412
+ if (Array.isArray(input)) {
413
+ msgs = input;
414
+ } else if (typeof input === "string") {
415
+ const content: Array<TextContent | ImageContent> = [{ type: "text", text: input }];
416
+ if (images && images.length > 0) {
417
+ content.push(...images);
418
+ }
419
+ msgs = [
420
+ {
421
+ role: "user",
422
+ content,
423
+ timestamp: Date.now(),
424
+ },
425
+ ];
426
+ } else {
427
+ msgs = [input];
428
+ }
429
+
430
+ await this._runLoop(msgs);
431
+ }
432
+
433
+ /**
434
+ * Continue from current context (used for retries and resuming queued messages).
435
+ */
436
+ async continue() {
437
+ if (this._state.isStreaming) {
438
+ throw new Error("Agent is already processing. Wait for completion before continuing.");
439
+ }
440
+
441
+ const messages = this._state.messages;
442
+ if (messages.length === 0) {
443
+ throw new Error("No messages to continue from");
444
+ }
445
+ if (messages[messages.length - 1].role === "assistant") {
446
+ const queuedSteering = this.dequeueSteeringMessages();
447
+ if (queuedSteering.length > 0) {
448
+ await this._runLoop(queuedSteering, { skipInitialSteeringPoll: true });
449
+ return;
450
+ }
451
+
452
+ const queuedFollowUp = this.dequeueFollowUpMessages();
453
+ if (queuedFollowUp.length > 0) {
454
+ await this._runLoop(queuedFollowUp);
455
+ return;
456
+ }
457
+
458
+ throw new Error("Cannot continue from message role: assistant");
459
+ }
460
+
461
+ await this._runLoop(undefined);
462
+ }
463
+
464
+ private _processLoopEvent(event: AgentEvent): void {
465
+ switch (event.type) {
466
+ case "message_start":
467
+ this._state.streamMessage = event.message;
468
+ break;
469
+
470
+ case "message_update":
471
+ this._state.streamMessage = event.message;
472
+ break;
473
+
474
+ case "message_end":
475
+ this._state.streamMessage = null;
476
+ this.appendMessage(event.message);
477
+ break;
478
+
479
+ case "tool_execution_start": {
480
+ const pendingToolCalls = new Set(this._state.pendingToolCalls);
481
+ pendingToolCalls.add(event.toolCallId);
482
+ this._state.pendingToolCalls = pendingToolCalls;
483
+ break;
484
+ }
485
+
486
+ case "tool_execution_end": {
487
+ const pendingToolCalls = new Set(this._state.pendingToolCalls);
488
+ pendingToolCalls.delete(event.toolCallId);
489
+ this._state.pendingToolCalls = pendingToolCalls;
490
+ break;
491
+ }
492
+
493
+ case "turn_end":
494
+ if (event.message.role === "assistant" && (event.message as any).errorMessage) {
495
+ this._state.error = (event.message as any).errorMessage;
496
+ }
497
+ break;
498
+
499
+ case "agent_end":
500
+ this._state.isStreaming = false;
501
+ this._state.streamMessage = null;
502
+ break;
503
+ }
504
+
505
+ this.emit(event);
506
+ }
507
+
508
+ /**
509
+ * Run the agent loop.
510
+ * If messages are provided, starts a new conversation turn with those messages.
511
+ * Otherwise, continues from existing context.
512
+ */
513
+ private async _runLoop(messages?: AgentMessage[], options?: { skipInitialSteeringPoll?: boolean }) {
514
+ const model = this._state.model;
515
+ if (!model) throw new Error("No model configured");
516
+
517
+ this.runningPrompt = new Promise<void>((resolve) => {
518
+ this.resolveRunningPrompt = resolve;
519
+ });
520
+
521
+ this.abortController = new AbortController();
522
+ this._state.isStreaming = true;
523
+ this._state.streamMessage = null;
524
+ this._state.error = undefined;
525
+
526
+ const reasoning = this._state.thinkingLevel === "off" ? undefined : this._state.thinkingLevel;
527
+
528
+ const context: AgentContext = {
529
+ systemPrompt: this._state.systemPrompt,
530
+ messages: this._state.messages.slice(),
531
+ tools: this._state.tools,
532
+ };
533
+
534
+ let skipInitialSteeringPoll = options?.skipInitialSteeringPoll === true;
535
+
536
+ const config: AgentLoopConfig = {
537
+ model,
538
+ reasoning,
539
+ sessionId: this._sessionId,
540
+ onPayload: this._onPayload,
541
+ transport: this._transport,
542
+ thinkingBudgets: this._thinkingBudgets,
543
+ maxRetryDelayMs: this._maxRetryDelayMs,
544
+ toolExecution: this._toolExecution,
545
+ beforeToolCall: this._beforeToolCall,
546
+ afterToolCall: this._afterToolCall,
547
+ convertToLlm: this.convertToLlm,
548
+ transformContext: this.transformContext,
549
+ getApiKey: this.getApiKey,
550
+ getSteeringMessages: async () => {
551
+ if (skipInitialSteeringPoll) {
552
+ skipInitialSteeringPoll = false;
553
+ return [];
554
+ }
555
+ return this.dequeueSteeringMessages();
556
+ },
557
+ getFollowUpMessages: async () => this.dequeueFollowUpMessages(),
558
+ };
559
+
560
+ try {
561
+ if (messages) {
562
+ await runAgentLoop(
563
+ messages,
564
+ context,
565
+ config,
566
+ async (event) => this._processLoopEvent(event),
567
+ this.abortController.signal,
568
+ this.streamFn,
569
+ );
570
+ } else {
571
+ await runAgentLoopContinue(
572
+ context,
573
+ config,
574
+ async (event) => this._processLoopEvent(event),
575
+ this.abortController.signal,
576
+ this.streamFn,
577
+ );
578
+ }
579
+ } catch (err: any) {
580
+ const errorMsg: AgentMessage = {
581
+ role: "assistant",
582
+ content: [{ type: "text", text: "" }],
583
+ api: model.api,
584
+ provider: model.provider,
585
+ model: model.id,
586
+ usage: {
587
+ input: 0,
588
+ output: 0,
589
+ cacheRead: 0,
590
+ cacheWrite: 0,
591
+ totalTokens: 0,
592
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
593
+ },
594
+ stopReason: this.abortController?.signal.aborted ? "aborted" : "error",
595
+ errorMessage: err?.message || String(err),
596
+ timestamp: Date.now(),
597
+ } as AgentMessage;
598
+
599
+ this.appendMessage(errorMsg);
600
+ this._state.error = err?.message || String(err);
601
+ this.emit({ type: "agent_end", messages: [errorMsg] });
602
+ } finally {
603
+ this._state.isStreaming = false;
604
+ this._state.streamMessage = null;
605
+ this._state.pendingToolCalls = new Set<string>();
606
+ this.abortController = undefined;
607
+ this.resolveRunningPrompt?.();
608
+ this.runningPrompt = undefined;
609
+ this.resolveRunningPrompt = undefined;
610
+ }
611
+ }
612
+
613
+ private emit(e: AgentEvent) {
614
+ for (const listener of this.listeners) {
615
+ listener(e);
616
+ }
617
+ }
618
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ // Core Agent
2
+ export * from "./agent.js";
3
+ // Loop functions
4
+ export * from "./agent-loop.js";
5
+ // Proxy utilities
6
+ export * from "./proxy.js";
7
+ // Types
8
+ export * from "./types.js";