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