@akagilnc/pi-workflow-roles 0.1.5204 → 0.1.5212

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.
@@ -1,861 +0,0 @@
1
- /**
2
- * Pi Institutional Sub-Session Open Seam (#518 / #233).
3
- * Adapts Pi AgentSession / ModelRuntime / ModelRegistry into the host-neutral
4
- * HostInstitutionalSessionHandle contract.
5
- * Zero Pi type leakage out of this module.
6
- */
7
- import { mkdtemp, rm } from "node:fs/promises";
8
- import { tmpdir } from "node:os";
9
- import { join } from "node:path";
10
-
11
- import {
12
- createAgentSession,
13
- DefaultResourceLoader,
14
- ModelRegistry,
15
- ModelRuntime,
16
- SettingsManager,
17
- type AgentToolResult,
18
- type SessionManager,
19
- type ToolDefinition,
20
- } from "@earendil-works/pi-coding-agent";
21
- import {
22
- createAssistantMessageEventStream,
23
- InMemoryCredentialStore,
24
- type Api,
25
- type AssistantMessage,
26
- type Model,
27
- type Provider,
28
- type ProviderStreamOptions,
29
- type TranscriptContext,
30
- type Usage,
31
- } from "@earendil-works/pi-ai";
32
-
33
- import { createRecordSession } from "../archivist-record-entry.ts";
34
- import type {
35
- HostAssistantTurnResult,
36
- HostInstitutionalSessionEvent,
37
- HostInstitutionalSessionHandle,
38
- HostInstitutionalSessionOptions,
39
- HostSessionUsage,
40
- } from "../host-contracts.ts";
41
- import {
42
- createStreamIdleGuard,
43
- isStreamIdleTimeoutError,
44
- StreamIdleTimeoutError,
45
- } from "../stream-idle-guard.ts";
46
- import {
47
- hasUpstreamErrorTestimony,
48
- isNonSuccessHttpStatus,
49
- projectConfirmedRemotePayload,
50
- } from "../upstream-error-testimony.ts";
51
-
52
- export const DEFAULT_COMPLIANCE_IDLE_MAX_RETRIES = 2;
53
-
54
- /** Recover a typed idle timeout from a thrown error or an error-stop message. */
55
- function streamIdleTimeoutFromUnknown(value: unknown): StreamIdleTimeoutError | undefined {
56
- if (isStreamIdleTimeoutError(value)) return value;
57
- const message = value instanceof Error ? value.message : typeof value === "string" ? value : undefined;
58
- if (message === undefined) return undefined;
59
- const match = /stream idle timeout after (\d+)ms/i.exec(message);
60
- return match !== null && match[1] !== undefined
61
- ? new StreamIdleTimeoutError(Number(match[1]))
62
- : undefined;
63
- }
64
-
65
-
66
- // ── Stream / remote error projection helpers ───────────────────────────────
67
-
68
- function emptyUsage(): Usage {
69
- return {
70
- input: 0,
71
- output: 0,
72
- cacheRead: 0,
73
- cacheWrite: 0,
74
- totalTokens: 0,
75
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
76
- };
77
- }
78
-
79
- function addUsage(total: Usage, next?: Usage): void {
80
- if (!next) return;
81
- total.input += next.input;
82
- total.output += next.output;
83
- total.cacheRead += next.cacheRead;
84
- total.cacheWrite += next.cacheWrite;
85
- total.totalTokens += next.totalTokens;
86
- total.cost.input += next.cost?.input ?? 0;
87
- total.cost.output += next.cost?.output ?? 0;
88
- total.cost.cacheRead += next.cost?.cacheRead ?? 0;
89
- total.cost.cacheWrite += next.cost?.cacheWrite ?? 0;
90
- total.cost.total += next.cost?.total ?? 0;
91
- }
92
-
93
- function numericHttpStatus(value: unknown): number | undefined {
94
- return isNonSuccessHttpStatus(value) ? value : undefined;
95
- }
96
-
97
- type StructuredRemoteProjection = {
98
- readonly hasTestimony: boolean;
99
- readonly httpStatus?: number;
100
- readonly diagnostics?: unknown;
101
- readonly body?: unknown;
102
- readonly code?: unknown;
103
- readonly errno?: unknown;
104
- };
105
-
106
- function projectStructuredRemote(error: unknown): StructuredRemoteProjection {
107
- let httpStatus: number | undefined;
108
- let diagnostics: unknown;
109
- let body: unknown;
110
- let code: unknown;
111
- let errno: unknown;
112
- let cursor: unknown = error;
113
- const seen = new Set<unknown>();
114
- while (typeof cursor === "object" && cursor !== null && !seen.has(cursor)) {
115
- seen.add(cursor);
116
- const record = cursor as Record<string, unknown>;
117
- const nodeStatus = numericHttpStatus(record.statusCode)
118
- ?? numericHttpStatus(record.status)
119
- ?? numericHttpStatus(record.httpStatus);
120
- const nodeDiagnostics = Array.isArray(record.diagnostics) && record.diagnostics.length > 0
121
- ? record.diagnostics
122
- : undefined;
123
- const nodeHasTestimony = hasUpstreamErrorTestimony({
124
- ...(nodeStatus === undefined ? {} : { httpStatus: nodeStatus }),
125
- ...(nodeDiagnostics === undefined ? {} : { diagnostics: nodeDiagnostics }),
126
- });
127
- if (httpStatus === undefined && nodeStatus !== undefined) httpStatus = nodeStatus;
128
- if (diagnostics === undefined && nodeDiagnostics !== undefined) diagnostics = nodeDiagnostics;
129
- if (nodeHasTestimony) {
130
- const payload = projectConfirmedRemotePayload(record);
131
- if (body === undefined && payload.body !== undefined) body = payload.body;
132
- if (code === undefined && payload.code !== undefined) code = payload.code;
133
- if (errno === undefined && payload.errno !== undefined) errno = payload.errno;
134
- }
135
- cursor = record.cause;
136
- }
137
- return {
138
- hasTestimony: hasUpstreamErrorTestimony({
139
- ...(httpStatus === undefined ? {} : { httpStatus }),
140
- ...(diagnostics === undefined ? {} : { diagnostics }),
141
- }),
142
- ...(httpStatus === undefined ? {} : { httpStatus }),
143
- ...(diagnostics === undefined ? {} : { diagnostics }),
144
- ...(body === undefined ? {} : { body }),
145
- ...(code === undefined ? {} : { code }),
146
- ...(errno === undefined ? {} : { errno }),
147
- };
148
- }
149
-
150
- function attachObservedHttpStatus<T extends AssistantMessage>(
151
- message: T,
152
- observedHttpStatus: number | undefined,
153
- ): T {
154
- if (observedHttpStatus === undefined) return message;
155
- if (message.stopReason !== "error" && message.stopReason !== "aborted") return message;
156
- if (numericHttpStatus(observedHttpStatus) === undefined) return message;
157
- if (projectStructuredRemote(message).httpStatus !== undefined) return message;
158
- return Object.assign(message, {
159
- status: observedHttpStatus,
160
- statusCode: observedHttpStatus,
161
- });
162
- }
163
-
164
- function enrichStreamEvent(event: unknown, observedHttpStatus: number | undefined): unknown {
165
- if (observedHttpStatus === undefined || event === null || typeof event !== "object") return event;
166
- const record = event as Record<string, unknown>;
167
- if (record.type === "error" && record.error !== null && typeof record.error === "object") {
168
- return {
169
- ...record,
170
- error: attachObservedHttpStatus(record.error as AssistantMessage, observedHttpStatus),
171
- };
172
- }
173
- if (record.type === "done" && record.message !== null && typeof record.message === "object") {
174
- return {
175
- ...record,
176
- message: attachObservedHttpStatus(record.message as AssistantMessage, observedHttpStatus),
177
- };
178
- }
179
- if (record.partial !== null && typeof record.partial === "object") {
180
- return {
181
- ...record,
182
- partial: attachObservedHttpStatus(record.partial as AssistantMessage, observedHttpStatus),
183
- };
184
- }
185
- return event;
186
- }
187
-
188
- // ── S3 Institutional Session Open Seam ─────────────────────────────────────
189
-
190
- export type OpenPiInstitutionalSessionOptions = HostInstitutionalSessionOptions & {
191
- readonly label?: string;
192
- };
193
-
194
- /**
195
- * Result of the Pi institutional open seam (#518 §1②).
196
- * The host-neutral handle is the open-turn-close surface and does not leak
197
- * AgentSession, ModelRuntime, or Provider objects out of the adapter.
198
- */
199
- export type OpenPiInstitutionalSessionResult = {
200
- readonly handle: HostInstitutionalSessionHandle;
201
- /**
202
- * Primary provider-stream failure held at the adapter boundary when a
203
- * provider stream errors after idle-only retries are exhausted. Consumers
204
- * that need to distinguish a step machine "error" response from a real
205
- * transport failure read this to preserve primary-cause fidelity
206
- * (ADR 0018 / 失败诚实宪法). Undefined while the session stays healthy.
207
- */
208
- readonly streamFailure: unknown;
209
- };
210
-
211
- export async function openPiInProcessSession(
212
- options: OpenPiInstitutionalSessionOptions,
213
- ): Promise<OpenPiInstitutionalSessionResult> {
214
- const label = options.label ?? "Institutional sub-session";
215
- const selection = options.selection;
216
-
217
- // 1. Scratch management
218
- let scratchDir: string | undefined;
219
- let resolvedAgentDir = options.agentDir;
220
- if (resolvedAgentDir === undefined) {
221
- scratchDir = await mkdtemp(join(options.credentialScratchParent ?? tmpdir(), "ak-institutional-"));
222
- resolvedAgentDir = scratchDir;
223
- }
224
-
225
- try {
226
- // 2. Auth resolution in Pi layer via explicit selection (Hop 3)
227
- // spec-2: adapter creates its OWN child-local ModelRuntime and ModelRegistry.
228
- // Auth is resolved strictly by explicit selection on the child registry — never
229
- // ambiently inherited from the parent ExtensionContext/modelRegistry.
230
- const childRuntime = await ModelRuntime.create();
231
- const childRegistry = new ModelRegistry(childRuntime);
232
-
233
- const childProvider: Provider | undefined = typeof childRegistry.getProvider === "function"
234
- ? childRegistry.getProvider(selection.provider)
235
- : undefined;
236
-
237
- const foundModel = typeof childRegistry.find === "function"
238
- ? childRegistry.find(selection.provider, selection.model)
239
- : undefined;
240
-
241
- const withTypedReason = (error: Error, reason: "auth" | "model" | "thinking"): Error =>
242
- Object.assign(error, { reason });
243
-
244
- // Existing Navigator/model contract (pre-#590): unknown provider or model is
245
- // typed unavailable/model — never auth. Auth runs only after the selection
246
- // resolves to a known provider surface.
247
- if (childProvider === undefined) {
248
- throw withTypedReason(
249
- new Error(`${label} model is unavailable: ${selection.provider}/${selection.model}`),
250
- "model",
251
- );
252
- }
253
-
254
- const providerDefaultModel = childProvider.getModels?.()[0];
255
- const fallbackApi = providerDefaultModel?.api
256
- ?? (childProvider as any)?.api
257
- ?? (selection.provider === "openai-codex" ? "openai-codex-responses" : "openai-completions");
258
- // Fallback model facts come from the provider surface only — never derive
259
- // reasoning (or any capability) from the thinking string (#683 pass-through).
260
- const modelToUse = foundModel ?? {
261
- id: selection.model,
262
- name: selection.model,
263
- api: fallbackApi as any,
264
- provider: selection.provider,
265
- baseUrl: providerDefaultModel?.baseUrl ?? "",
266
- reasoning: providerDefaultModel?.reasoning ?? false,
267
- input: ["text"],
268
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
269
- contextWindow: 128000,
270
- maxTokens: 16384,
271
- };
272
-
273
- let resolution: { auth: { baseUrl?: string; apiKey?: string; headers?: Record<string, string | null> }; env?: Record<string, string> } | undefined;
274
- if (typeof childRegistry.getProviderAuth === "function") {
275
- resolution = await childRegistry.getProviderAuth(selection.provider).catch((error: unknown) => {
276
- throw withTypedReason(
277
- new Error(`${label} authentication failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error }),
278
- "auth",
279
- );
280
- });
281
- if (resolution === undefined) {
282
- throw withTypedReason(
283
- new Error(`${label} authentication failed: provider is not configured: ${selection.provider}`),
284
- "auth",
285
- );
286
- }
287
- }
288
-
289
- let authResult: { ok: boolean; error?: string; apiKey?: string; headers?: Record<string, string | null>; env?: Record<string, string> } | undefined;
290
- if (typeof childRegistry.getApiKeyAndHeaders === "function") {
291
- authResult = await childRegistry.getApiKeyAndHeaders(modelToUse as any);
292
- if (authResult && !authResult.ok) {
293
- throw withTypedReason(
294
- new Error(`${label} authentication failed: ${authResult.error}`),
295
- "auth",
296
- );
297
- }
298
- }
299
-
300
- const resolvedApiKey = authResult?.apiKey ?? resolution?.auth?.apiKey;
301
- const resolvedHeaders = authResult?.headers ?? resolution?.auth?.headers;
302
- const resolvedEnv = authResult?.env ?? resolution?.env;
303
- const effectiveBaseUrl = resolution?.auth?.baseUrl ?? modelToUse.baseUrl;
304
-
305
- const effectiveModel: Model<Api> = {
306
- ...modelToUse,
307
- baseUrl: effectiveBaseUrl,
308
- };
309
-
310
- // 3. Standalone ModelRuntime + Provider with idle-only retry.
311
- // spec-2: the adapter constructs its OWN runtime/provider registration.
312
- // Auth is resolved only by explicit selection above and seeded into the
313
- // child runtime's own credential store — never inherited ambiently from
314
- // the parent ExtensionContext/Provider.
315
- const credentials = new InMemoryCredentialStore();
316
- const runtime = await ModelRuntime.create({
317
- credentials,
318
- modelsPath: null,
319
- });
320
-
321
- // Seed the child runtime's own credential store with the explicitly
322
- // resolved auth so stream auth resolution is self-contained. No ambient
323
- // fallback: absence of a resolved apiKey is not silently supplied.
324
- if (resolvedApiKey !== undefined) {
325
- await credentials.modify(selection.provider, async () => ({
326
- type: "api_key",
327
- key: resolvedApiKey,
328
- ...(resolvedEnv === undefined ? {} : { env: resolvedEnv }),
329
- }));
330
- }
331
-
332
- const abortReason = (signal: AbortSignal): unknown =>
333
- signal.reason ?? new Error(`${label} provider stream aborted`);
334
-
335
- let streamFailureValue: unknown;
336
-
337
- async function waitForStream<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
338
- if (signal.aborted) throw abortReason(signal);
339
- let onAbort: (() => void) | undefined;
340
- try {
341
- return await Promise.race([
342
- promise,
343
- new Promise<never>((_resolve, reject) => {
344
- onAbort = () => reject(abortReason(signal));
345
- signal.addEventListener("abort", onAbort, { once: true });
346
- }),
347
- ]);
348
- } finally {
349
- if (onAbort !== undefined) signal.removeEventListener("abort", onAbort);
350
- }
351
- }
352
-
353
- const createRetriedStream = (
354
- simple: boolean,
355
- model: Model<Api>,
356
- context: TranscriptContext,
357
- request?: ProviderStreamOptions,
358
- ): ReturnType<Provider["stream"]> => {
359
- const wrapped = createAssistantMessageEventStream();
360
- void (async () => {
361
- for (let attempt = 0; ; attempt += 1) {
362
- const idle = createStreamIdleGuard(
363
- options.signal === undefined ? {} : { parentSignal: options.signal },
364
- );
365
- let observedHttpStatus: number | undefined;
366
- let observedHttpPayload: ReturnType<typeof projectConfirmedRemotePayload> | undefined;
367
- try {
368
- const requestSignal = request?.signal;
369
- const streamSignal = requestSignal === undefined
370
- ? idle.signal
371
- : AbortSignal.any([idle.signal, requestSignal]);
372
- const priorOnResponse = request?.onResponse;
373
- const priorFetch = request?.fetch;
374
- const baseFetch = priorFetch ?? globalThis.fetch.bind(globalThis);
375
- // Capture wire status from the real Response before the provider SDK
376
- // folds non-2xx into errorMessage-only assistant stops (no free-text parse).
377
- // A structured Response is testimony — including 5xx. Local/unrecognized
378
- // failures never produce a Response, so they stay unlabelled.
379
- const statusAwareFetch: typeof fetch = async (input, init) => {
380
- const response = await baseFetch(input, init);
381
- if (isNonSuccessHttpStatus(response?.status)) {
382
- observedHttpStatus = response.status;
383
- try {
384
- const parsed: unknown = JSON.parse(await response.clone().text());
385
- if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
386
- observedHttpPayload = projectConfirmedRemotePayload(parsed);
387
- }
388
- } catch {
389
- // Payload observation must not break the provider stream.
390
- }
391
- }
392
- return response;
393
- };
394
- const retriedRequest: ProviderStreamOptions = {
395
- ...(request ?? {}),
396
- ...(resolvedEnv === undefined ? {} : { env: resolvedEnv }),
397
- signal: streamSignal,
398
- maxRetries: 0,
399
- fetch: statusAwareFetch,
400
- onResponse: async (
401
- response: { status: number; headers: Record<string, string> },
402
- resModel: Model<Api>,
403
- ) => {
404
- if (typeof response?.status === "number") observedHttpStatus = response.status;
405
- await priorOnResponse?.(response, resModel);
406
- },
407
- };
408
-
409
- if (childProvider === undefined) {
410
- throw withTypedReason(
411
- new Error(`${label} provider not found: ${model.provider}`),
412
- "model",
413
- );
414
- }
415
-
416
- const source = simple
417
- ? childProvider.streamSimple(model, context, retriedRequest as any)
418
- : childProvider.stream(model, context, retriedRequest as any);
419
- let sawEvent = false;
420
- const attemptEvents: any[] = [];
421
- const iterator = source[Symbol.asyncIterator]();
422
- while (true) {
423
- const next = await waitForStream(iterator.next(), idle.signal);
424
- if (next.done) break;
425
- sawEvent = true;
426
- idle.poke();
427
- attemptEvents.push(enrichStreamEvent(next.value, observedHttpStatus));
428
- }
429
- const response = attachObservedHttpStatus(
430
- await waitForStream(source.result(), idle.signal),
431
- observedHttpStatus,
432
- );
433
- // Some stream APIs (e.g. OpenAI-completions over an HTTP error)
434
- // surface the failure as an error-stop message rather than throwing.
435
- // Hold that primary provider failure at the adapter boundary so
436
- // consumers surface a real transport failure, never a projected
437
- // step-machine "error" response (ADR 0018 / #518 §3).
438
- if (response.stopReason === "error") {
439
- const errorMessage = response.errorMessage ?? `${label} provider stream failed`;
440
- const idleFailure = streamIdleTimeoutFromUnknown(errorMessage);
441
- if (
442
- options.idleRetry !== false
443
- && idleFailure !== undefined
444
- && attempt < DEFAULT_COMPLIANCE_IDLE_MAX_RETRIES
445
- && options.signal?.aborted !== true
446
- ) {
447
- continue;
448
- }
449
- const failure = idleFailure ?? new Error(errorMessage, { cause: response });
450
- // Only structured status: onResponse observation or fields already on the
451
- // assistant message. Never parse errorMessage prose for HTTP status.
452
- const httpStatus = numericHttpStatus(observedHttpStatus)
453
- ?? numericHttpStatus((response as { statusCode?: unknown }).statusCode)
454
- ?? numericHttpStatus((response as { status?: unknown }).status);
455
- if (httpStatus !== undefined) {
456
- Object.assign(failure, {
457
- statusCode: httpStatus,
458
- status: httpStatus,
459
- ...(observedHttpPayload ?? {}),
460
- });
461
- Object.assign(response, {
462
- statusCode: httpStatus,
463
- status: httpStatus,
464
- ...(observedHttpPayload ?? {}),
465
- });
466
- }
467
- streamFailureValue = failure;
468
- }
469
- for (const ev of attemptEvents) {
470
- wrapped.push(ev as any);
471
- }
472
- wrapped.end(response);
473
- return;
474
- } catch (error) {
475
- if (request?.signal?.aborted) {
476
- const response: AssistantMessage = {
477
- role: "assistant",
478
- content: [],
479
- api: model.api,
480
- provider: model.provider,
481
- model: model.id,
482
- usage: emptyUsage(),
483
- stopReason: "aborted",
484
- errorMessage: `${label} session aborted`,
485
- timestamp: Date.now(),
486
- };
487
- wrapped.push({ type: "error", reason: "aborted", error: response });
488
- wrapped.end(response);
489
- return;
490
- }
491
- const failure = isStreamIdleTimeoutError(idle.signal.reason) ? idle.signal.reason : error;
492
- const idleFailure = streamIdleTimeoutFromUnknown(failure);
493
- if (
494
- options.idleRetry !== false
495
- && idleFailure !== undefined
496
- && attempt < DEFAULT_COMPLIANCE_IDLE_MAX_RETRIES
497
- && options.signal?.aborted !== true
498
- ) {
499
- continue;
500
- }
501
- const typedFailure = idleFailure ?? failure;
502
- const projected = projectStructuredRemote(failure);
503
- const httpStatus = projected.httpStatus ?? numericHttpStatus(observedHttpStatus);
504
- const payload = projectConfirmedRemotePayload({
505
- body: projected.body ?? observedHttpPayload?.body,
506
- code: projected.code ?? observedHttpPayload?.code,
507
- errno: projected.errno ?? observedHttpPayload?.errno,
508
- });
509
- // Hold the primary provider failure at the adapter boundary (ADR
510
- // 0018 / 失败诚实宪法). Attach observed HTTP status when held so
511
- // host-neutral consumers (navigator/auditor) can classify auth/quota
512
- // without reading free-text errorMessage.
513
- if (
514
- httpStatus !== undefined
515
- && typeof typedFailure === "object"
516
- && typedFailure !== null
517
- ) {
518
- Object.assign(typedFailure, { statusCode: httpStatus, status: httpStatus, ...payload });
519
- }
520
- streamFailureValue = typedFailure;
521
- const response = {
522
- role: "assistant" as const,
523
- content: [] as [],
524
- api: model.api,
525
- provider: model.provider,
526
- model: model.id,
527
- usage: emptyUsage(),
528
- stopReason: "error" as const,
529
- errorMessage: failure instanceof Error ? failure.message : String(failure),
530
- timestamp: Date.now(),
531
- ...(projected.diagnostics === undefined ? {} : { diagnostics: projected.diagnostics }),
532
- ...(httpStatus === undefined ? {} : { status: httpStatus, statusCode: httpStatus }),
533
- ...payload,
534
- } as unknown as AssistantMessage;
535
- wrapped.push({ type: "error", reason: "error", error: response });
536
- wrapped.end(response);
537
- return;
538
- } finally {
539
- idle.dispose();
540
- }
541
- }
542
- })();
543
- return wrapped as ReturnType<Provider["stream"]>;
544
- };
545
-
546
- const provider: Provider = {
547
- id: selection.provider,
548
- name: childProvider?.name ?? label,
549
- ...(effectiveBaseUrl ? { baseUrl: effectiveBaseUrl } : {}),
550
- ...(resolvedHeaders ? { headers: resolvedHeaders as any } : {}),
551
- auth: {
552
- apiKey: {
553
- name: `${label} authentication`,
554
- async resolve() {
555
- return {
556
- auth: {
557
- ...(resolvedApiKey === undefined ? {} : { apiKey: resolvedApiKey }),
558
- ...(resolvedHeaders === undefined ? {} : { headers: resolvedHeaders }),
559
- ...(effectiveBaseUrl === undefined ? {} : { baseUrl: effectiveBaseUrl }),
560
- },
561
- ...(resolvedEnv === undefined ? {} : { env: resolvedEnv }),
562
- };
563
- },
564
- },
565
- },
566
- getModels() { return [effectiveModel]; },
567
- stream(model, childContext, request) {
568
- return createRetriedStream(false, model, childContext, request as ProviderStreamOptions | undefined);
569
- },
570
- streamSimple(model, childContext, request) {
571
- return createRetriedStream(true, model, childContext, request as ProviderStreamOptions | undefined);
572
- },
573
- };
574
-
575
- runtime.registerNativeProvider(provider);
576
- await runtime.refresh({ allowNetwork: false });
577
-
578
- // 4. Settings and ResourceLoader (fixed adapter policies: no extensions/skills/themes/templates/context-files)
579
- const settings = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } });
580
- const loader = new DefaultResourceLoader({
581
- cwd: options.cwd,
582
- agentDir: resolvedAgentDir,
583
- settingsManager: settings,
584
- noExtensions: true,
585
- noSkills: true,
586
- noPromptTemplates: true,
587
- noThemes: true,
588
- noContextFiles: true,
589
- systemPrompt: options.systemPrompt,
590
- });
591
- await loader.reload();
592
-
593
- // 5. SessionManager resolution
594
- let sessionManager: SessionManager;
595
- if (options.sessionManager !== undefined) {
596
- sessionManager = options.sessionManager as SessionManager;
597
- } else if (options.sessionIdentity !== undefined) {
598
- sessionManager = createRecordSession({
599
- cwd: options.cwd,
600
- kind: options.sessionIdentity.kind,
601
- ...(options.sessionIdentity.subject === undefined ? {} : { subject: options.sessionIdentity.subject }),
602
- ...(options.sessionIdentity.parent === undefined ? {} : { parent: options.sessionIdentity.parent }),
603
- }) as SessionManager;
604
- } else {
605
- sessionManager = createRecordSession({
606
- cwd: options.cwd,
607
- kind: "institutional",
608
- }) as SessionManager;
609
- }
610
-
611
- // 6. Tools mapping
612
- const customTools: ToolDefinition[] = [];
613
- if (options.customTools !== undefined) {
614
- customTools.push(...(options.customTools as ToolDefinition[]));
615
- }
616
- if (options.tools !== undefined) {
617
- for (const hostTool of options.tools) {
618
- customTools.push({
619
- name: hostTool.name,
620
- label: hostTool.label,
621
- description: hostTool.description,
622
- parameters: hostTool.parameters,
623
- execute: async (toolCallId, params, signal, update, _ctx) => {
624
- const res = await hostTool.execute(
625
- toolCallId,
626
- params as any,
627
- signal,
628
- update === undefined ? undefined : (u) => update(u as AgentToolResult<unknown>),
629
- undefined as any,
630
- );
631
- return res as AgentToolResult<unknown>;
632
- },
633
- });
634
- }
635
- }
636
-
637
- // 7. Create AgentSession
638
- // Thinking is opaque pass-through (#683 / #675 ⑥). Absent selection omits
639
- // thinkingLevel (Pi owns default). Pi clamps unsupported levels itself;
640
- // we do not re-check or invent package defaults. When reusing a session file,
641
- // re-apply seat model after open so Pi cannot restore a stale model over
642
- // selection (#675 ⑤ / #697).
643
- const requestedThinking = selection.thinking;
644
- const priorEntries =
645
- typeof (sessionManager as { getEntries?: () => readonly unknown[] }).getEntries === "function"
646
- ? (sessionManager as { getEntries: () => readonly unknown[] }).getEntries()
647
- : [];
648
- const reusedSession = priorEntries.some((entry) => {
649
- if (typeof entry !== "object" || entry === null) return false;
650
- const type = (entry as { type?: unknown }).type;
651
- return type === "message" || type === "model_change" || type === "thinking_level_change";
652
- });
653
- const { session } = await createAgentSession({
654
- cwd: options.cwd,
655
- model: effectiveModel,
656
- ...(requestedThinking === undefined ? {} : { thinkingLevel: requestedThinking as any }),
657
- modelRuntime: runtime,
658
- sessionManager,
659
- settingsManager: settings,
660
- agentDir: resolvedAgentDir,
661
- resourceLoader: loader,
662
- ...(options.noTools === undefined ? {} : { noTools: options.noTools }),
663
- ...(options.toolsAllowlist === undefined ? {} : { tools: options.toolsAllowlist as string[] }),
664
- ...(customTools.length === 0 ? {} : { customTools }),
665
- });
666
- if (reusedSession) {
667
- try {
668
- await session.setModel(effectiveModel);
669
- if (requestedThinking !== undefined) {
670
- session.setThinkingLevel(requestedThinking as any);
671
- }
672
- } catch (error) {
673
- session.dispose();
674
- throw withTypedReason(
675
- error instanceof Error
676
- ? error
677
- : new Error(
678
- `${label} failed to apply seat model/thinking for ${selection.provider}/${selection.model}`,
679
- ),
680
- requestedThinking !== undefined ? "thinking" : "model",
681
- );
682
- }
683
- }
684
-
685
- // 8. Event subscriptions
686
- const listeners = new Set<(event: HostInstitutionalSessionEvent) => void>();
687
- const accumulatedUsage = emptyUsage();
688
-
689
- let lastEmittedAssistant: AssistantMessage | undefined;
690
- const unsubscribeSession = session.subscribe((event) => {
691
- if (event.type === "message_end") {
692
- const msg = event.message as AssistantMessage;
693
- if (msg.role === "assistant") {
694
- lastEmittedAssistant = msg;
695
- if (msg.usage) {
696
- addUsage(accumulatedUsage, msg.usage);
697
- }
698
- }
699
- for (const listener of listeners) {
700
- listener({
701
- type: "message_end",
702
- role: msg.role,
703
- message: msg,
704
- ...(msg.usage === undefined ? {} : { usage: msg.usage as HostSessionUsage }),
705
- });
706
- }
707
- } else if (event.type === "turn_end") {
708
- const msg = event.message as AssistantMessage;
709
- for (const listener of listeners) {
710
- listener({
711
- type: "turn_end",
712
- ...(msg.stopReason === undefined ? {} : { stopReason: msg.stopReason }),
713
- });
714
- }
715
- } else if (event.type === "tool_execution_start") {
716
- for (const listener of listeners) {
717
- listener({
718
- type: "tool_call",
719
- toolCallId: event.toolCallId,
720
- toolName: event.toolName,
721
- args: (event as any).args ?? (event as any).input,
722
- });
723
- }
724
- } else if (event.type === "tool_execution_end") {
725
- for (const listener of listeners) {
726
- listener({
727
- type: "tool_result",
728
- toolCallId: event.toolCallId,
729
- toolName: event.toolName,
730
- isError: event.isError,
731
- details: (event as any).result ?? (event as any).details,
732
- });
733
- }
734
- }
735
- });
736
-
737
- let closed = false;
738
- const sessionFile = sessionManager.getSessionFile();
739
- const sessionId = (sessionManager as any).getHeader?.()?.id;
740
-
741
- const handle: HostInstitutionalSessionHandle = {
742
- ...(sessionFile === undefined ? {} : { sessionFile }),
743
- ...(sessionId === undefined ? {} : { sessionId }),
744
-
745
- async prompt(text: string): Promise<HostAssistantTurnResult> {
746
- const abortSession = () => { void session.abort(); };
747
- if (options.signal?.aborted) abortSession();
748
- else options.signal?.addEventListener("abort", abortSession, { once: true });
749
-
750
- const assistantCountBefore = session.messages.filter(
751
- (message) => message.role === "assistant",
752
- ).length;
753
- let promptError: unknown;
754
- try {
755
- await session.prompt(text);
756
- } catch (error) {
757
- promptError = error;
758
- } finally {
759
- options.signal?.removeEventListener("abort", abortSession);
760
- }
761
-
762
- const lastAssistant = [...session.messages]
763
- .reverse()
764
- .find((message) => message.role === "assistant") as AssistantMessage | undefined;
765
-
766
- if (lastAssistant !== undefined && lastAssistant !== lastEmittedAssistant) {
767
- lastEmittedAssistant = lastAssistant;
768
- for (const listener of listeners) {
769
- listener({
770
- type: "message_end",
771
- role: lastAssistant.role,
772
- message: lastAssistant,
773
- ...(lastAssistant.usage === undefined ? {} : { usage: lastAssistant.usage as HostSessionUsage }),
774
- });
775
- }
776
- }
777
-
778
- const producedAssistant = session.messages.filter(
779
- (message) => message.role === "assistant",
780
- ).length > assistantCountBefore;
781
- if (promptError !== undefined && !producedAssistant) {
782
- throw promptError;
783
- }
784
-
785
- return {
786
- text: session.getLastAssistantText() ?? "",
787
- ...(lastAssistant?.stopReason === undefined ? {} : { stopReason: lastAssistant.stopReason }),
788
- ...(lastAssistant?.errorMessage === undefined ? {} : { errorMessage: lastAssistant.errorMessage }),
789
- usage: accumulatedUsage as HostSessionUsage,
790
- messages: session.messages,
791
- };
792
- },
793
-
794
- subscribe(listener: (event: HostInstitutionalSessionEvent) => void): () => void {
795
- listeners.add(listener);
796
- return () => {
797
- listeners.delete(listener);
798
- };
799
- },
800
-
801
- abort(): void {
802
- void session.abort();
803
- },
804
-
805
- async close(): Promise<void> {
806
- if (closed) return;
807
- closed = true;
808
- listeners.clear();
809
- const cleanupFailures: unknown[] = [];
810
- try {
811
- unsubscribeSession();
812
- } catch (error) {
813
- cleanupFailures.push(error);
814
- }
815
- try {
816
- session.dispose();
817
- } catch (error) {
818
- cleanupFailures.push(error);
819
- }
820
- if (scratchDir !== undefined) {
821
- try {
822
- await rm(scratchDir, { recursive: true, force: true });
823
- } catch (error) {
824
- cleanupFailures.push(error);
825
- }
826
- }
827
- if (cleanupFailures.length === 1) throw cleanupFailures[0];
828
- if (cleanupFailures.length > 1) {
829
- throw new AggregateError(cleanupFailures, `${label} close cleanup failures`, {
830
- cause: cleanupFailures[0],
831
- });
832
- }
833
- },
834
- };
835
-
836
- return {
837
- handle,
838
- // Read lazily: the provider stream runs during session.prompt, so the
839
- // primary failure is only known after that turn completes. A getter keeps
840
- // this reflecting the latest value instead of freezing it at open time.
841
- get streamFailure() {
842
- return streamFailureValue;
843
- },
844
- };
845
- } catch (openError) {
846
- if (scratchDir !== undefined) {
847
- try {
848
- await rm(scratchDir, { recursive: true, force: true });
849
- } catch (cleanupFailure) {
850
- // ADR 0018 / #518 §1④: primary failure and cleanup failure stay
851
- // separated — cleanup must not mask the original open cause.
852
- throw new AggregateError(
853
- [openError, cleanupFailure],
854
- `${label} open failed and its scratch cleanup also failed`,
855
- { cause: openError },
856
- );
857
- }
858
- }
859
- throw openError;
860
- }
861
- }