@ai-sdk/harness-pi 1.0.72 → 1.0.74
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/CHANGELOG.md +17 -0
- package/dist/index.js +420 -110
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/pi-session.ts +501 -100
- package/src/pi-workspace-mirror.ts +132 -41
package/src/pi-session.ts
CHANGED
|
@@ -249,6 +249,30 @@ interface PendingToolApproval {
|
|
|
249
249
|
interface ActivePiTurn {
|
|
250
250
|
readonly token: object;
|
|
251
251
|
readonly done: Promise<void>;
|
|
252
|
+
readonly abort: (reason?: unknown) => Promise<void>;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* A host tool call recorded in the restored journal without a matching tool
|
|
257
|
+
* result — it was awaiting host input (typically a tool approval) when the
|
|
258
|
+
* process that owned the live turn went away.
|
|
259
|
+
*/
|
|
260
|
+
interface DanglingHostToolCall {
|
|
261
|
+
readonly toolCallId: string;
|
|
262
|
+
readonly toolName: string;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Barrier that holds a cross-process rerun until the framework has
|
|
267
|
+
* re-delivered the results for every journal-pending host tool call.
|
|
268
|
+
*/
|
|
269
|
+
interface DeferredRerunBarrier {
|
|
270
|
+
/** toolCallId -> toolName still awaiting a submitted result. */
|
|
271
|
+
readonly awaiting: Map<string, string>;
|
|
272
|
+
readonly startRerun: () => void;
|
|
273
|
+
/** Settle the barrier without running: resolves `done` cleanly when no
|
|
274
|
+
* reason is given, rejects it otherwise. No-op once the rerun started. */
|
|
275
|
+
readonly cancel: (reason?: unknown) => void;
|
|
252
276
|
}
|
|
253
277
|
|
|
254
278
|
export async function createPiSession(
|
|
@@ -509,6 +533,20 @@ export async function createPiSession(
|
|
|
509
533
|
let suspending = false;
|
|
510
534
|
const pendingToolResults = new Map<string, PendingToolResult>();
|
|
511
535
|
const pendingToolApprovals = new Map<string, PendingToolApproval>();
|
|
536
|
+
/*
|
|
537
|
+
* Results the framework submitted for journal-pending (dangling) host tool
|
|
538
|
+
* calls while no live turn held a promise for them — the cross-process
|
|
539
|
+
* continuation path. They are written into the restored journal before the
|
|
540
|
+
* rerun (or on suspend/stop, so a later resume still sees them).
|
|
541
|
+
*/
|
|
542
|
+
const deliveredDanglingResults = new Map<
|
|
543
|
+
string,
|
|
544
|
+
{ toolName: string; output: unknown; isError: boolean }
|
|
545
|
+
>();
|
|
546
|
+
let restoredSessionManager:
|
|
547
|
+
| ReturnType<typeof SessionManager.open>
|
|
548
|
+
| undefined;
|
|
549
|
+
let deferredRerun: DeferredRerunBarrier | undefined;
|
|
512
550
|
|
|
513
551
|
// Emit channel set at the start of every doPromptTurn and cleared on end.
|
|
514
552
|
let currentEmit: ((part: HarnessV1StreamPart) => void) | undefined;
|
|
@@ -565,17 +603,258 @@ export async function createPiSession(
|
|
|
565
603
|
});
|
|
566
604
|
}
|
|
567
605
|
|
|
606
|
+
function getRestoredSessionManager():
|
|
607
|
+
| ReturnType<typeof SessionManager.open>
|
|
608
|
+
| undefined {
|
|
609
|
+
if (resumeSessionFilePath == null) return undefined;
|
|
610
|
+
restoredSessionManager ??= SessionManager.open(
|
|
611
|
+
resumeSessionFilePath,
|
|
612
|
+
hostSessionDir,
|
|
613
|
+
sessionWorkDir,
|
|
614
|
+
);
|
|
615
|
+
return restoredSessionManager;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/*
|
|
619
|
+
* Host tool calls in the restored journal that never received a result on
|
|
620
|
+
* the active branch. These are the calls that were blocked on host input
|
|
621
|
+
* (typically a tool approval) when the process owning the live turn exited;
|
|
622
|
+
* the framework re-delivers their results via `submitToolResult` right after
|
|
623
|
+
* `doContinueTurn` returns. Only meaningful before the first rebuild of a
|
|
624
|
+
* resumed session — once a Pi session is live, pending host input is held as
|
|
625
|
+
* in-process promises instead.
|
|
626
|
+
*/
|
|
627
|
+
function findDanglingHostToolCalls(
|
|
628
|
+
userTools: ReadonlyArray<HarnessV1ToolSpec>,
|
|
629
|
+
): DanglingHostToolCall[] {
|
|
630
|
+
if (piSession != null || resumeSessionFilePath == null) return [];
|
|
631
|
+
const hostToolNames = new Set(userTools.map(tool => tool.name));
|
|
632
|
+
if (hostToolNames.size === 0) return [];
|
|
633
|
+
const journal = getRestoredSessionManager();
|
|
634
|
+
if (journal == null) return [];
|
|
635
|
+
const messages = journal.buildSessionContext().messages;
|
|
636
|
+
/*
|
|
637
|
+
* Results already delivered by a previous continuation of this session
|
|
638
|
+
* count as resolved even though they are not in the journal yet — the
|
|
639
|
+
* framework has marked them settled and will never re-deliver them, so a
|
|
640
|
+
* new barrier must not wait on them (it would deadlock the turn). They
|
|
641
|
+
* are injected into the journal before the rerun.
|
|
642
|
+
*/
|
|
643
|
+
const resolvedToolCallIds = new Set<string>(
|
|
644
|
+
deliveredDanglingResults.keys(),
|
|
645
|
+
);
|
|
646
|
+
for (const message of messages) {
|
|
647
|
+
if (message.role === 'toolResult') {
|
|
648
|
+
resolvedToolCallIds.add(message.toolCallId);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const dangling: DanglingHostToolCall[] = [];
|
|
652
|
+
for (const message of messages) {
|
|
653
|
+
if (message.role !== 'assistant') continue;
|
|
654
|
+
/*
|
|
655
|
+
* Pi's message transform drops errored/aborted assistant messages from
|
|
656
|
+
* the LLM context entirely, so their tool calls are not awaiting
|
|
657
|
+
* results — the model retries from the last valid state instead.
|
|
658
|
+
*/
|
|
659
|
+
if (message.stopReason === 'error' || message.stopReason === 'aborted') {
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
for (const block of message.content) {
|
|
663
|
+
if (
|
|
664
|
+
block.type === 'toolCall' &&
|
|
665
|
+
hostToolNames.has(block.name) &&
|
|
666
|
+
!resolvedToolCallIds.has(block.id)
|
|
667
|
+
) {
|
|
668
|
+
dangling.push({ toolCallId: block.id, toolName: block.name });
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return dangling;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/*
|
|
676
|
+
* A result submitted while no live turn holds a pending promise for its
|
|
677
|
+
* toolCallId. On the cross-process continuation path this is the framework
|
|
678
|
+
* re-delivering the caller's tool-approval/tool-result continuation for a
|
|
679
|
+
* journal-pending call; stash it for injection and release the rerun once
|
|
680
|
+
* every dangling call has its result. Results for ids that are neither live
|
|
681
|
+
* nor journal-pending have nowhere to go and are dropped, as before.
|
|
682
|
+
*/
|
|
683
|
+
function acceptDanglingHostToolResult(args: {
|
|
684
|
+
toolCallId: string;
|
|
685
|
+
output: unknown;
|
|
686
|
+
isError?: boolean;
|
|
687
|
+
}): void {
|
|
688
|
+
const barrier = deferredRerun;
|
|
689
|
+
const toolName = barrier?.awaiting.get(args.toolCallId);
|
|
690
|
+
if (barrier == null || toolName == null) return;
|
|
691
|
+
barrier.awaiting.delete(args.toolCallId);
|
|
692
|
+
deliveredDanglingResults.set(args.toolCallId, {
|
|
693
|
+
toolName,
|
|
694
|
+
output: args.output,
|
|
695
|
+
isError: args.isError ?? false,
|
|
696
|
+
});
|
|
697
|
+
if (barrier.awaiting.size === 0) {
|
|
698
|
+
barrier.startRerun();
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/*
|
|
703
|
+
* Write delivered dangling-call results into the restored journal so the
|
|
704
|
+
* rerun's context carries the real outputs — without this, Pi's message
|
|
705
|
+
* transform synthesizes an error result ("No result provided") for each
|
|
706
|
+
* dangling call and the model continues as if the tool never answered. The
|
|
707
|
+
* serialized text matches what a live turn would have produced
|
|
708
|
+
* (`asPiToolResult(serializeToolOutput(...))`), so the model sees the same
|
|
709
|
+
* bytes either way.
|
|
710
|
+
*/
|
|
711
|
+
function appendDeliveredHostToolResults(): boolean {
|
|
712
|
+
if (deliveredDanglingResults.size === 0 || resumeSessionFilePath == null) {
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
const journal = getRestoredSessionManager();
|
|
716
|
+
if (journal == null) return false;
|
|
717
|
+
for (const [toolCallId, delivered] of deliveredDanglingResults) {
|
|
718
|
+
journal.appendMessage({
|
|
719
|
+
role: 'toolResult',
|
|
720
|
+
toolCallId,
|
|
721
|
+
toolName: delivered.toolName,
|
|
722
|
+
content: [
|
|
723
|
+
{ type: 'text', text: serializeToolOutput(delivered.output) },
|
|
724
|
+
],
|
|
725
|
+
isError: delivered.isError,
|
|
726
|
+
timestamp: Date.now(),
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
deliveredDanglingResults.clear();
|
|
730
|
+
/*
|
|
731
|
+
* The journal on disk now differs from the copy in the sandbox. Make sure
|
|
732
|
+
* the lifecycle persistence knows which file to push back even when no
|
|
733
|
+
* turn ever rebuilt the Pi session in this process (e.g. a suspend that
|
|
734
|
+
* lands while the rerun is still held back).
|
|
735
|
+
*/
|
|
736
|
+
if (!sessionFileName) {
|
|
737
|
+
sessionFileName = safePiSessionFileName(
|
|
738
|
+
path.basename(resumeSessionFilePath),
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/*
|
|
745
|
+
* Cross-process continuation of a turn that paused on host input: the
|
|
746
|
+
* restored journal ends with host tool calls that have no results, and the
|
|
747
|
+
* framework re-delivers those results through `control.submitToolResult`
|
|
748
|
+
* (with the original tool-call ids) immediately after this call returns.
|
|
749
|
+
* Starting the rerun right away would race that delivery — the rerun's
|
|
750
|
+
* context would resolve the dangling calls as synthetic empty results and
|
|
751
|
+
* the submitted outputs would be dropped. Hold the rerun until every
|
|
752
|
+
* dangling call's result has arrived, write the results into the journal,
|
|
753
|
+
* and only then re-drive the turn.
|
|
754
|
+
*
|
|
755
|
+
* If the caller resumes without supplying all continuations, the turn stays
|
|
756
|
+
* parked awaiting the remaining host input — the same behaviour as the
|
|
757
|
+
* in-process path, where the live turn stays blocked on its tool promises.
|
|
758
|
+
*/
|
|
759
|
+
function deferRerunUntilHostToolResults(
|
|
760
|
+
danglingCalls: ReadonlyArray<DanglingHostToolCall>,
|
|
761
|
+
continueOpts: HarnessV1ContinueTurnOptions,
|
|
762
|
+
): HarnessV1PromptControl {
|
|
763
|
+
/*
|
|
764
|
+
* A previous continuation may have ended while its rerun was still held
|
|
765
|
+
* back (e.g. it paused again awaiting a tool-result continuation). Close
|
|
766
|
+
* that turn's control cleanly before installing the new barrier.
|
|
767
|
+
*/
|
|
768
|
+
deferredRerun?.cancel();
|
|
769
|
+
|
|
770
|
+
let resolveDone!: () => void;
|
|
771
|
+
let rejectDone!: (error: unknown) => void;
|
|
772
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
773
|
+
resolveDone = resolve;
|
|
774
|
+
rejectDone = reject;
|
|
775
|
+
});
|
|
776
|
+
let settled = false;
|
|
777
|
+
|
|
778
|
+
const startRerun = () => {
|
|
779
|
+
if (settled) return;
|
|
780
|
+
settled = true;
|
|
781
|
+
deferredRerun = undefined;
|
|
782
|
+
void (async () => {
|
|
783
|
+
try {
|
|
784
|
+
// `runTurn` injects the delivered results into the journal before
|
|
785
|
+
// rebuilding the Pi session from it.
|
|
786
|
+
const control = await runTurn({
|
|
787
|
+
text: '',
|
|
788
|
+
tools: continueOpts.tools ?? [],
|
|
789
|
+
instructions: continueOpts.instructions,
|
|
790
|
+
emit: continueOpts.emit,
|
|
791
|
+
abortSignal: continueOpts.abortSignal,
|
|
792
|
+
});
|
|
793
|
+
await control.done;
|
|
794
|
+
resolveDone();
|
|
795
|
+
} catch (error) {
|
|
796
|
+
rejectDone(error);
|
|
797
|
+
}
|
|
798
|
+
})();
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
const cancel = (reason?: unknown) => {
|
|
802
|
+
if (settled) return;
|
|
803
|
+
settled = true;
|
|
804
|
+
deferredRerun = undefined;
|
|
805
|
+
if (reason == null) {
|
|
806
|
+
resolveDone();
|
|
807
|
+
} else {
|
|
808
|
+
rejectDone(reason);
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
deferredRerun = {
|
|
813
|
+
awaiting: new Map(
|
|
814
|
+
danglingCalls.map(call => [call.toolCallId, call.toolName]),
|
|
815
|
+
),
|
|
816
|
+
startRerun,
|
|
817
|
+
cancel,
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
const abortBarrier = () => {
|
|
821
|
+
cancel(
|
|
822
|
+
continueOpts.abortSignal?.reason ??
|
|
823
|
+
new Error(
|
|
824
|
+
'Pi turn was aborted before its host tool results were delivered.',
|
|
825
|
+
),
|
|
826
|
+
);
|
|
827
|
+
};
|
|
828
|
+
if (continueOpts.abortSignal?.aborted) {
|
|
829
|
+
abortBarrier();
|
|
830
|
+
} else {
|
|
831
|
+
continueOpts.abortSignal?.addEventListener('abort', abortBarrier, {
|
|
832
|
+
once: true,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return createPromptControl({
|
|
837
|
+
done,
|
|
838
|
+
abortSignal: continueOpts.abortSignal,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
568
842
|
function createPromptControl(input: {
|
|
569
843
|
done: Promise<void>;
|
|
570
844
|
abortSignal?: AbortSignal;
|
|
845
|
+
abort?: (reason?: unknown) => Promise<void>;
|
|
571
846
|
}): HarnessV1PromptControl {
|
|
572
847
|
const abortHandler = () => {
|
|
573
|
-
|
|
848
|
+
void input.abort?.(input.abortSignal?.reason);
|
|
574
849
|
};
|
|
575
850
|
if (input.abortSignal) {
|
|
576
|
-
input.abortSignal.
|
|
577
|
-
|
|
578
|
-
}
|
|
851
|
+
if (input.abortSignal.aborted) {
|
|
852
|
+
abortHandler();
|
|
853
|
+
} else {
|
|
854
|
+
input.abortSignal.addEventListener('abort', abortHandler, {
|
|
855
|
+
once: true,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
579
858
|
void input.done.then(
|
|
580
859
|
() => {
|
|
581
860
|
input.abortSignal?.removeEventListener('abort', abortHandler);
|
|
@@ -589,7 +868,10 @@ export async function createPiSession(
|
|
|
589
868
|
return {
|
|
590
869
|
async submitToolResult(args) {
|
|
591
870
|
const pending = pendingToolResults.get(args.toolCallId);
|
|
592
|
-
if (!pending)
|
|
871
|
+
if (!pending) {
|
|
872
|
+
acceptDanglingHostToolResult(args);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
593
875
|
pendingToolResults.delete(args.toolCallId);
|
|
594
876
|
/*
|
|
595
877
|
* Preserve the original output so the result projection can surface it
|
|
@@ -713,11 +995,7 @@ export async function createPiSession(
|
|
|
713
995
|
// session; create fresh otherwise.
|
|
714
996
|
const sessionManager =
|
|
715
997
|
isFirstBuild && resumeSessionFilePath
|
|
716
|
-
?
|
|
717
|
-
resumeSessionFilePath,
|
|
718
|
-
hostSessionDir,
|
|
719
|
-
sessionWorkDir,
|
|
720
|
-
)
|
|
998
|
+
? getRestoredSessionManager()!
|
|
721
999
|
: SessionManager.create(sessionWorkDir, hostSessionDir);
|
|
722
1000
|
|
|
723
1001
|
const { session } = await createAgentSession({
|
|
@@ -781,6 +1059,7 @@ export async function createPiSession(
|
|
|
781
1059
|
async function runTurn(turnOpts: {
|
|
782
1060
|
text: string;
|
|
783
1061
|
tools: ReadonlyArray<HarnessV1ToolSpec>;
|
|
1062
|
+
instructions?: string;
|
|
784
1063
|
emit: (part: HarnessV1StreamPart) => void;
|
|
785
1064
|
abortSignal?: AbortSignal;
|
|
786
1065
|
}): Promise<HarnessV1PromptControl> {
|
|
@@ -789,101 +1068,164 @@ export async function createPiSession(
|
|
|
789
1068
|
}
|
|
790
1069
|
|
|
791
1070
|
const userTools = turnOpts.tools;
|
|
792
|
-
const signature = JSON.stringify(userTools.map(t => t.name).sort());
|
|
793
|
-
const needsRebuild = piSession == null || signature !== lastToolsSignature;
|
|
794
|
-
let resourcesReloaded = false;
|
|
795
|
-
if (needsRebuild) {
|
|
796
|
-
resourcesReloaded = await rebuildPiSession(userTools, piSession == null);
|
|
797
|
-
lastToolsSignature = signature;
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
if (!resourcesReloaded) {
|
|
801
|
-
await reloadResourcesOnly();
|
|
802
|
-
}
|
|
803
|
-
await syncHostWorkspaceFromSandbox({
|
|
804
|
-
sandbox,
|
|
805
|
-
sandboxWorkDir: input.sessionWorkDir,
|
|
806
|
-
hostWorkDir,
|
|
807
|
-
});
|
|
808
|
-
|
|
809
1071
|
currentEmit = turnOpts.emit;
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
1072
|
+
const turnAbortController = new AbortController();
|
|
1073
|
+
const abort = async (reason?: unknown): Promise<void> => {
|
|
1074
|
+
if (turnAbortController.signal.aborted) return;
|
|
1075
|
+
if (reason === undefined) {
|
|
1076
|
+
turnAbortController.abort();
|
|
1077
|
+
} else {
|
|
1078
|
+
turnAbortController.abort(reason);
|
|
1079
|
+
}
|
|
1080
|
+
await Promise.resolve(piSession?.abort()).catch(() => {});
|
|
1081
|
+
};
|
|
819
1082
|
|
|
820
1083
|
const turnPromise = (async () => {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1084
|
+
try {
|
|
1085
|
+
await applySessionInstructions(turnOpts.instructions);
|
|
1086
|
+
turnAbortController.signal.throwIfAborted();
|
|
1087
|
+
|
|
1088
|
+
/*
|
|
1089
|
+
* Any host tool results delivered while no turn was live must land in the
|
|
1090
|
+
* journal before the session (re)builds from it, whichever turn entry
|
|
1091
|
+
* point runs next. No-op when nothing was delivered.
|
|
1092
|
+
*/
|
|
1093
|
+
const didAppendDeliveredHostToolResults =
|
|
1094
|
+
appendDeliveredHostToolResults();
|
|
1095
|
+
|
|
1096
|
+
const signature = JSON.stringify(userTools.map(t => t.name).sort());
|
|
1097
|
+
const needsRebuild =
|
|
1098
|
+
piSession == null || signature !== lastToolsSignature;
|
|
1099
|
+
let resourcesReloaded = false;
|
|
1100
|
+
if (needsRebuild) {
|
|
1101
|
+
resourcesReloaded = await rebuildPiSession(
|
|
1102
|
+
userTools,
|
|
1103
|
+
piSession == null,
|
|
1104
|
+
);
|
|
1105
|
+
turnAbortController.signal.throwIfAborted();
|
|
1106
|
+
lastToolsSignature = signature;
|
|
832
1107
|
}
|
|
833
|
-
});
|
|
834
1108
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
if (terminalError) {
|
|
839
|
-
/*
|
|
840
|
-
* A `doSuspendTurn` aborts the in-flight turn on purpose. Pi surfaces
|
|
841
|
-
* that abort as a *resolved* prompt with a recorded terminal error
|
|
842
|
-
* ("This operation was aborted") rather than a thrown exception, so the
|
|
843
|
-
* `catch` guard below never sees it. Swallow it here too — but only if
|
|
844
|
-
* it's actually the abort: the stream then closes cleanly (no spurious
|
|
845
|
-
* `error` chunk) and the next slice rerun-continues from the journal.
|
|
846
|
-
* Any other terminal error mid-suspend is unanticipated and must
|
|
847
|
-
* surface.
|
|
848
|
-
*/
|
|
849
|
-
if (suspending && isAbortError(terminalError)) return;
|
|
850
|
-
currentEmit?.({ type: 'error', error: new Error(terminalError) });
|
|
851
|
-
return;
|
|
1109
|
+
if (!resourcesReloaded) {
|
|
1110
|
+
await reloadResourcesOnly();
|
|
1111
|
+
turnAbortController.signal.throwIfAborted();
|
|
852
1112
|
}
|
|
1113
|
+
await syncHostWorkspaceFromSandbox({
|
|
1114
|
+
sandbox,
|
|
1115
|
+
sandboxWorkDir: input.sessionWorkDir,
|
|
1116
|
+
hostWorkDir,
|
|
1117
|
+
});
|
|
1118
|
+
turnAbortController.signal.throwIfAborted();
|
|
1119
|
+
|
|
1120
|
+
// Fresh translator state for the new turn — keep the tool sets the
|
|
1121
|
+
// session was built with.
|
|
1122
|
+
translatorState = createPiTranslatorState({
|
|
1123
|
+
builtinToolNames: [...PI_NATIVE_BUILTIN_NAMES],
|
|
1124
|
+
hostToolNames: userTools.map(tool => tool.name),
|
|
1125
|
+
nativeToCommon: NATIVE_TO_COMMON,
|
|
1126
|
+
});
|
|
853
1127
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1128
|
+
currentEmit?.({ type: 'stream-start' });
|
|
1129
|
+
|
|
1130
|
+
/*
|
|
1131
|
+
* A live continuation reports the completed tool execution before the
|
|
1132
|
+
* next assistant message, which closes the resumed tool-call step. A
|
|
1133
|
+
* journal rerun starts after that result has already been persisted,
|
|
1134
|
+
* so Pi has no live tool event to emit. Recreate only the missing step
|
|
1135
|
+
* boundary; otherwise the continuation layer mistakes the next
|
|
1136
|
+
* assistant response for the resumed step and discards it.
|
|
1137
|
+
*/
|
|
1138
|
+
if (didAppendDeliveredHostToolResults) {
|
|
1139
|
+
currentEmit?.({
|
|
1140
|
+
type: 'finish-step',
|
|
1141
|
+
finishReason: { unified: 'tool-calls', raw: undefined },
|
|
1142
|
+
usage: {
|
|
1143
|
+
inputTokens: {
|
|
1144
|
+
total: 0,
|
|
1145
|
+
noCache: 0,
|
|
1146
|
+
cacheRead: 0,
|
|
1147
|
+
cacheWrite: 0,
|
|
1148
|
+
},
|
|
1149
|
+
outputTokens: {
|
|
1150
|
+
total: 0,
|
|
1151
|
+
text: 0,
|
|
1152
|
+
reasoning: 0,
|
|
1153
|
+
},
|
|
1154
|
+
},
|
|
1155
|
+
harnessMetadata: { pi: { inferredStep: true } },
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
let terminalError: string | undefined;
|
|
1160
|
+
const session = piSession!;
|
|
1161
|
+
|
|
1162
|
+
// We subscribed in rebuild, but the translator may need to detect
|
|
1163
|
+
// terminal errors too — wrap a second listener that records them.
|
|
1164
|
+
const unsubErr = session.subscribe(raw => {
|
|
1165
|
+
const ev = parseNativeEvent(raw);
|
|
1166
|
+
if (!ev) return;
|
|
1167
|
+
const err = getPiTerminalError(ev);
|
|
1168
|
+
if (err && !terminalError) {
|
|
1169
|
+
terminalError = err;
|
|
1170
|
+
}
|
|
876
1171
|
});
|
|
1172
|
+
|
|
1173
|
+
try {
|
|
1174
|
+
await session.prompt(turnOpts.text);
|
|
1175
|
+
|
|
1176
|
+
if (terminalError) {
|
|
1177
|
+
/*
|
|
1178
|
+
* A `doSuspendTurn` aborts the in-flight turn on purpose. Pi surfaces
|
|
1179
|
+
* that abort as a *resolved* prompt with a recorded terminal error
|
|
1180
|
+
* ("This operation was aborted") rather than a thrown exception, so the
|
|
1181
|
+
* `catch` guard below never sees it. Swallow it here too — but only if
|
|
1182
|
+
* it's actually the abort: the stream then closes cleanly (no spurious
|
|
1183
|
+
* `error` chunk) and the next slice rerun-continues from the journal.
|
|
1184
|
+
* Any other terminal error mid-suspend is unanticipated and must
|
|
1185
|
+
* surface.
|
|
1186
|
+
*/
|
|
1187
|
+
if (suspending && isAbortError(terminalError)) return;
|
|
1188
|
+
currentEmit?.({ type: 'error', error: new Error(terminalError) });
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const stats = session.getSessionStats();
|
|
1193
|
+
const finishReason = {
|
|
1194
|
+
unified: 'stop' as const,
|
|
1195
|
+
raw: undefined,
|
|
1196
|
+
};
|
|
1197
|
+
const usage = {
|
|
1198
|
+
inputTokens: {
|
|
1199
|
+
total: stats.tokens.input,
|
|
1200
|
+
noCache: undefined,
|
|
1201
|
+
cacheRead: stats.tokens.cacheRead,
|
|
1202
|
+
cacheWrite: stats.tokens.cacheWrite,
|
|
1203
|
+
},
|
|
1204
|
+
outputTokens: {
|
|
1205
|
+
total: stats.tokens.output,
|
|
1206
|
+
text: undefined,
|
|
1207
|
+
reasoning: undefined,
|
|
1208
|
+
},
|
|
1209
|
+
};
|
|
1210
|
+
currentEmit?.({
|
|
1211
|
+
type: 'finish',
|
|
1212
|
+
finishReason,
|
|
1213
|
+
totalUsage: usage,
|
|
1214
|
+
});
|
|
1215
|
+
} catch (err) {
|
|
1216
|
+
// A `doSuspendTurn` aborts the in-flight turn on purpose — settle silently
|
|
1217
|
+
// so the stream closes cleanly without a spurious `error` chunk; the
|
|
1218
|
+
// next slice rerun-continues from the persisted journal.
|
|
1219
|
+
// Same rule as the resolved-with-terminalError path: only swallow the
|
|
1220
|
+
// abort our own suspend caused; surface anything unanticipated.
|
|
1221
|
+
if (suspending && isAbortError(err)) return;
|
|
1222
|
+
currentEmit?.({ type: 'error', error: err });
|
|
1223
|
+
} finally {
|
|
1224
|
+
unsubErr();
|
|
1225
|
+
}
|
|
877
1226
|
} catch (err) {
|
|
878
|
-
// A `doSuspendTurn` aborts the in-flight turn on purpose — settle silently
|
|
879
|
-
// so the stream closes cleanly without a spurious `error` chunk; the
|
|
880
|
-
// next slice rerun-continues from the persisted journal.
|
|
881
|
-
// Same rule as the resolved-with-terminalError path: only swallow the
|
|
882
|
-
// abort our own suspend caused; surface anything unanticipated.
|
|
883
1227
|
if (suspending && isAbortError(err)) return;
|
|
884
|
-
|
|
885
|
-
} finally {
|
|
886
|
-
unsubErr();
|
|
1228
|
+
throw err;
|
|
887
1229
|
}
|
|
888
1230
|
})();
|
|
889
1231
|
|
|
@@ -891,17 +1233,19 @@ export async function createPiSession(
|
|
|
891
1233
|
const done = turnPromise.finally(() => {
|
|
892
1234
|
if (activeTurn?.token === activeTurnToken) {
|
|
893
1235
|
activeTurn = undefined;
|
|
1236
|
+
currentEmit = undefined;
|
|
894
1237
|
}
|
|
895
|
-
currentEmit = undefined;
|
|
896
1238
|
});
|
|
897
1239
|
activeTurn = {
|
|
898
1240
|
token: activeTurnToken,
|
|
899
1241
|
done,
|
|
1242
|
+
abort,
|
|
900
1243
|
};
|
|
901
1244
|
|
|
902
1245
|
return createPromptControl({
|
|
903
1246
|
done,
|
|
904
1247
|
abortSignal: turnOpts.abortSignal,
|
|
1248
|
+
abort,
|
|
905
1249
|
});
|
|
906
1250
|
}
|
|
907
1251
|
|
|
@@ -911,8 +1255,24 @@ export async function createPiSession(
|
|
|
911
1255
|
}
|
|
912
1256
|
stopped = true;
|
|
913
1257
|
parkedPiSessions.delete(input.sessionId);
|
|
1258
|
+
deferredRerun?.cancel();
|
|
1259
|
+
const turnToStop = activeTurn;
|
|
1260
|
+
const abortingTurn = turnToStop?.abort();
|
|
914
1261
|
settlePendingToolResults('Pi session stopped');
|
|
915
1262
|
settlePendingToolApprovals('Pi session stopped');
|
|
1263
|
+
await abortingTurn;
|
|
1264
|
+
await turnToStop?.done.catch(() => {});
|
|
1265
|
+
|
|
1266
|
+
/*
|
|
1267
|
+
* Results the framework already delivered for journal-pending calls must
|
|
1268
|
+
* reach the journal before it is persisted — the framework has marked
|
|
1269
|
+
* them settled and will not re-deliver them on a later resume.
|
|
1270
|
+
*/
|
|
1271
|
+
try {
|
|
1272
|
+
appendDeliveredHostToolResults();
|
|
1273
|
+
} catch {
|
|
1274
|
+
// Best-effort: an unwritable journal falls back to the pre-delivery copy.
|
|
1275
|
+
}
|
|
916
1276
|
|
|
917
1277
|
// Persist the Pi session file into the sandbox so a future process
|
|
918
1278
|
// can pick it up after `provider.resumeSession({ sessionId })` reattaches.
|
|
@@ -951,11 +1311,10 @@ export async function createPiSession(
|
|
|
951
1311
|
doPromptTurn: async (
|
|
952
1312
|
promptOpts: HarnessV1PromptTurnOptions,
|
|
953
1313
|
): Promise<HarnessV1PromptControl> => {
|
|
954
|
-
await applySessionInstructions(promptOpts.instructions);
|
|
955
|
-
|
|
956
1314
|
return runTurn({
|
|
957
1315
|
text: extractUserText(promptOpts.prompt),
|
|
958
1316
|
tools: promptOpts.tools ?? [],
|
|
1317
|
+
instructions: promptOpts.instructions,
|
|
959
1318
|
emit: promptOpts.emit,
|
|
960
1319
|
abortSignal: promptOpts.abortSignal,
|
|
961
1320
|
});
|
|
@@ -969,9 +1328,31 @@ export async function createPiSession(
|
|
|
969
1328
|
return createPromptControl({
|
|
970
1329
|
done: activeTurn.done,
|
|
971
1330
|
abortSignal: continueOpts.abortSignal,
|
|
1331
|
+
abort: activeTurn.abort,
|
|
972
1332
|
});
|
|
973
1333
|
}
|
|
974
1334
|
|
|
1335
|
+
if (stopped) {
|
|
1336
|
+
throw new Error('Pi session has been stopped.');
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/*
|
|
1340
|
+
* The restored journal ends with host tool calls that never got their
|
|
1341
|
+
* results — the turn was paused on host input (e.g. a tool approval)
|
|
1342
|
+
* when the previous process exited. The framework re-delivers those
|
|
1343
|
+
* results via `submitToolResult` right after this call returns; hold
|
|
1344
|
+
* the rerun until they have all arrived so they reach the model.
|
|
1345
|
+
*/
|
|
1346
|
+
const danglingHostToolCalls = findDanglingHostToolCalls(
|
|
1347
|
+
continueOpts.tools ?? [],
|
|
1348
|
+
);
|
|
1349
|
+
if (danglingHostToolCalls.length > 0) {
|
|
1350
|
+
return deferRerunUntilHostToolResults(
|
|
1351
|
+
danglingHostToolCalls,
|
|
1352
|
+
continueOpts,
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
975
1356
|
/*
|
|
976
1357
|
* Pi runs the model on the host, so there is no live turn in the sandbox
|
|
977
1358
|
* to attach to — the previous slice's turn died with its process.
|
|
@@ -980,10 +1361,10 @@ export async function createPiSession(
|
|
|
980
1361
|
* flight at the slice boundary is recomputed because a host-resident
|
|
981
1362
|
* runtime cannot do a lossless attach.
|
|
982
1363
|
*/
|
|
983
|
-
await applySessionInstructions(continueOpts.instructions);
|
|
984
1364
|
return runTurn({
|
|
985
1365
|
text: '',
|
|
986
1366
|
tools: continueOpts.tools ?? [],
|
|
1367
|
+
instructions: continueOpts.instructions,
|
|
987
1368
|
emit: continueOpts.emit,
|
|
988
1369
|
abortSignal: continueOpts.abortSignal,
|
|
989
1370
|
});
|
|
@@ -1006,8 +1387,13 @@ export async function createPiSession(
|
|
|
1006
1387
|
if (stopped) return;
|
|
1007
1388
|
stopped = true;
|
|
1008
1389
|
parkedPiSessions.delete(input.sessionId);
|
|
1390
|
+
deferredRerun?.cancel();
|
|
1391
|
+
const turnToDestroy = activeTurn;
|
|
1392
|
+
const abortingTurn = turnToDestroy?.abort();
|
|
1009
1393
|
settlePendingToolResults('Pi session stopped');
|
|
1010
1394
|
settlePendingToolApprovals('Pi session stopped');
|
|
1395
|
+
await abortingTurn;
|
|
1396
|
+
await turnToDestroy?.done.catch(() => {});
|
|
1011
1397
|
await disposePiSession();
|
|
1012
1398
|
workspaceVfs.unmount();
|
|
1013
1399
|
await rm(hostRoot, { recursive: true, force: true });
|
|
@@ -1077,7 +1463,22 @@ export async function createPiSession(
|
|
|
1077
1463
|
* turn the way a bridge adapter can.
|
|
1078
1464
|
*/
|
|
1079
1465
|
suspending = true;
|
|
1080
|
-
|
|
1466
|
+
const turnToSuspend = activeTurn;
|
|
1467
|
+
await turnToSuspend?.abort();
|
|
1468
|
+
deferredRerun?.cancel();
|
|
1469
|
+
await turnToSuspend?.done.catch(() => {});
|
|
1470
|
+
|
|
1471
|
+
/*
|
|
1472
|
+
* A suspend can land while the rerun is still held back waiting for
|
|
1473
|
+
* host tool results. Whatever the framework already delivered must land
|
|
1474
|
+
* in the journal now — it will not be re-delivered — while calls still
|
|
1475
|
+
* awaiting results stay dangling for the next continuation to collect.
|
|
1476
|
+
*/
|
|
1477
|
+
try {
|
|
1478
|
+
appendDeliveredHostToolResults();
|
|
1479
|
+
} catch {
|
|
1480
|
+
// Best-effort: an unwritable journal falls back to the pre-delivery copy.
|
|
1481
|
+
}
|
|
1081
1482
|
|
|
1082
1483
|
if (sessionFileName) {
|
|
1083
1484
|
try {
|