@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/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
- piSession?.abort().catch(() => {});
849
+ void input.abort?.(input.abortSignal?.reason);
574
850
  };
575
851
  if (input.abortSignal) {
576
- input.abortSignal.addEventListener('abort', abortHandler, {
577
- once: true,
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) return;
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
- ? SessionManager.open(
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
- // Fresh translator state for the new turn — keep the tool sets the
811
- // session was built with.
812
- translatorState = createPiTranslatorState({
813
- builtinToolNames: [...PI_NATIVE_BUILTIN_NAMES],
814
- hostToolNames: userTools.map(tool => tool.name),
815
- nativeToCommon: NATIVE_TO_COMMON,
816
- });
817
-
818
- turnOpts.emit({ type: 'stream-start' });
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
- let terminalError: string | undefined;
822
- const session = piSession!;
823
-
824
- // We subscribed in rebuild, but the translator may need to detect
825
- // terminal errors too — wrap a second listener that records them.
826
- const unsubErr = session.subscribe(raw => {
827
- const ev = parseNativeEvent(raw);
828
- if (!ev) return;
829
- const err = getPiTerminalError(ev);
830
- if (err && !terminalError) {
831
- terminalError = err;
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
- try {
836
- await session.prompt(turnOpts.text);
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
- const stats = session.getSessionStats();
855
- const finishReason = {
856
- unified: 'stop' as const,
857
- raw: undefined,
858
- };
859
- const usage = {
860
- inputTokens: {
861
- total: stats.tokens.input,
862
- noCache: undefined,
863
- cacheRead: stats.tokens.cacheRead,
864
- cacheWrite: stats.tokens.cacheWrite,
865
- },
866
- outputTokens: {
867
- total: stats.tokens.output,
868
- text: undefined,
869
- reasoning: undefined,
870
- },
871
- };
872
- currentEmit?.({
873
- type: 'finish',
874
- finishReason,
875
- totalUsage: usage,
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
- currentEmit?.({ type: 'error', error: err });
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
- await applySessionInstructions(promptOpts.instructions);
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
- await Promise.resolve(piSession?.abort()).catch(() => {});
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 {