pi-crew 0.11.22 → 0.11.24
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 +22 -0
- package/agents/explorer.md +3 -3
- package/agents/librarian.md +3 -3
- package/dist/index.mjs +523 -520
- package/docs/dynamic-workflows.md +14 -2
- package/package.json +4 -4
- package/src/agents/discover-agents.ts +8 -2
- package/src/config/config-validation.ts +3 -1
- package/src/config/env-allowlist-registry.ts +5 -0
- package/src/config/env-vars.ts +9 -6
- package/src/config/role-tools.ts +7 -6
- package/src/extension/management.ts +7 -0
- package/src/extension/register.ts +15 -0
- package/src/extension/run-index.ts +24 -1
- package/src/extension/team-tool/destructive-gate.ts +208 -0
- package/src/extension/team-tool/goal-wrap.ts +11 -1
- package/src/extension/team-tool/goal.ts +13 -1
- package/src/extension/team-tool/respond.ts +8 -6
- package/src/extension/team-tool/run.ts +4 -4
- package/src/extension/team-tool.ts +10 -0
- package/src/observability/exporters/otlp-exporter.ts +11 -1
- package/src/prompt/prompt-runtime.ts +102 -18
- package/src/prompt/surface-worker.ts +13 -1
- package/src/runtime/broker/crew-broker-client.ts +7 -0
- package/src/runtime/broker/crew-broker.ts +113 -152
- package/src/runtime/broker/protocol/connection-state.ts +159 -1
- package/src/runtime/broker/protocol/events-replay.ts +50 -10
- package/src/runtime/broker/protocol/request-parsers.ts +68 -0
- package/src/runtime/child-pi/child-pi-constants.ts +9 -0
- package/src/runtime/child-pi/child-pi-rpc.ts +72 -7
- package/src/runtime/child-pi/child-pi-streams.ts +19 -3
- package/src/runtime/child-pi/postmortem-registry.ts +8 -2
- package/src/runtime/classifier/ambient-noise-gate.ts +5 -1
- package/src/runtime/compaction/tool-output-pruner.ts +20 -2
- package/src/runtime/crew-agent-records.ts +92 -17
- package/src/runtime/custom-tools/submit-result-tool.ts +17 -0
- package/src/runtime/deadletter.ts +5 -1
- package/src/runtime/dispatch-batch.ts +60 -16
- package/src/runtime/effectiveness.ts +24 -1
- package/src/runtime/finalize-run.ts +12 -1
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +12 -3
- package/src/runtime/goal-workflow/goal-evaluator.ts +30 -5
- package/src/runtime/goal-workflow/goal-loop-runner.ts +60 -3
- package/src/runtime/live-session/live-agent-control.ts +34 -14
- package/src/runtime/live-session/live-agent-manager.ts +20 -5
- package/src/runtime/live-session/live-session-runtime.ts +66 -81
- package/src/runtime/live-session/usage-bridge.ts +66 -2
- package/src/runtime/mcp-proxy.ts +10 -0
- package/src/runtime/model/model-fallback.ts +68 -0
- package/src/runtime/model/runtime-policy.ts +20 -6
- package/src/runtime/model/runtime-resolver.ts +5 -3
- package/src/runtime/orphan-worker-registry.ts +64 -24
- package/src/runtime/pi-spawn.ts +4 -1
- package/src/runtime/process/zombie-scanner.ts +6 -2
- package/src/runtime/recovery/crash-recovery.ts +35 -2
- package/src/runtime/scheduling/batch-barrier.ts +42 -5
- package/src/runtime/scheduling/run-coalesced-task-group.ts +9 -3
- package/src/runtime/scheduling/scheduler.ts +74 -29
- package/src/runtime/stale-reconciler.ts +45 -6
- package/src/runtime/surface/herdr-provider.ts +54 -1
- package/src/runtime/surface/launch-script.ts +7 -1
- package/src/runtime/surface/resolve-surface.ts +4 -1
- package/src/runtime/surface/tmux-provider.ts +32 -2
- package/src/runtime/task-runner/child-executor.ts +3 -2
- package/src/runtime/workspace-lock.ts +52 -4
- package/src/state/contracts.ts +2 -0
- package/src/state/coordination/process-lock.ts +50 -0
- package/src/state/event-log/cursor.ts +24 -3
- package/src/state/event-log/event-log-rotation.ts +26 -2
- package/src/state/event-log/worker-atomic-writer.ts +5 -0
- package/src/state/stores/active-run-registry.ts +56 -5
- package/src/state/stores/ownership-map.ts +26 -7
- package/src/state/stores/plan-store.ts +30 -1
- package/src/state/stores/sqlite-run-state.ts +91 -1
- package/src/state/types.ts +5 -0
- package/src/ui/dashboard-panes/agents-pane.ts +4 -3
- package/src/ui/dashboard-panes/plan-pane.ts +5 -3
- package/src/ui/dashboard-panes/transcript-pane.ts +2 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +5 -3
- package/src/ui/run-dashboard.ts +6 -4
- package/src/ui/run-snapshot-cache.ts +3 -2
- package/src/ui/settings-overlay.ts +68 -10
- package/src/ui/theme-discovery.ts +26 -7
- package/src/ui/transcript-viewer.ts +5 -3
- package/src/utils/visual.ts +84 -0
|
@@ -549,6 +549,21 @@ export function clearLiveAgentsForTest(): void {
|
|
|
549
549
|
invalidateSortedLiveAgents();
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
+
/**
|
|
553
|
+
* R2-M-B3 (Wave-3): fence UNTRUSTED peer message content before it enters a
|
|
554
|
+
* recipient session. Peer DMs/broadcasts cross trust tiers (e.g. an
|
|
555
|
+
* explorer-mapped worker injecting into a writer session) and were previously
|
|
556
|
+
* delivered with only a `[DM from X]` prefix — raw, unfenced. Wrap in an
|
|
557
|
+
* `<untrusted-peer-message>` fence and strip any literal closing-tag
|
|
558
|
+
* occurrences from BOTH the content and the sender id so worker-authored
|
|
559
|
+
* text cannot escape the fence and pose as session-authored instructions.
|
|
560
|
+
*/
|
|
561
|
+
function fencePeerMessage(from: string, content: string): string {
|
|
562
|
+
const safeFrom = String(from).split("</untrusted-peer-message").join("");
|
|
563
|
+
const safeContent = String(content).split("</untrusted-peer-message").join("");
|
|
564
|
+
return `<untrusted-peer-message from="${safeFrom}">\n${safeContent}\n</untrusted-peer-message>`;
|
|
565
|
+
}
|
|
566
|
+
|
|
552
567
|
/** Phase 7/G4: Send an IRC message to a specific live agent (DM).
|
|
553
568
|
* Uses non-blocking delivery via sendCustomMessage when available.
|
|
554
569
|
* Falls back to session.prompt (blocking) when not.
|
|
@@ -569,7 +584,7 @@ export function sendIrcMessage(targetAgentId: string, message: IrcMessage): void
|
|
|
569
584
|
(session.sendCustomMessage as (msg: unknown, opts?: unknown) => void)(
|
|
570
585
|
{
|
|
571
586
|
customType: "irc",
|
|
572
|
-
content: `[DM from ${message.from}] ${message.content}`,
|
|
587
|
+
content: `[DM from ${message.from}] ${fencePeerMessage(message.from, message.content)}`,
|
|
573
588
|
display: "collapsed",
|
|
574
589
|
},
|
|
575
590
|
{ deliverAs: "followUp", triggerTurn: false },
|
|
@@ -581,7 +596,7 @@ export function sendIrcMessage(targetAgentId: string, message: IrcMessage): void
|
|
|
581
596
|
}
|
|
582
597
|
// Fallback: inject as prompt (blocking)
|
|
583
598
|
if (typeof handle.session.prompt === "function") {
|
|
584
|
-
const ircPrompt = `[Message from ${message.from}] ${message.content}`;
|
|
599
|
+
const ircPrompt = `[Message from ${message.from}] ${fencePeerMessage(message.from, message.content)}`;
|
|
585
600
|
void handle.session
|
|
586
601
|
.prompt(ircPrompt, { source: "api", expandPromptTemplates: false, streamingBehavior: "followUp" })
|
|
587
602
|
.catch((error) => logInternalError("live-agent-manager.irc-deliver", error, `agentId=${handle.agentId}`));
|
|
@@ -610,7 +625,7 @@ export function broadcastIrcMessage(fromAgentId: string, message: IrcMessage): s
|
|
|
610
625
|
(session.sendCustomMessage as (msg: unknown, opts?: unknown) => void)(
|
|
611
626
|
{
|
|
612
627
|
customType: "irc",
|
|
613
|
-
content: `[Broadcast from ${message.from}] ${message.content}`,
|
|
628
|
+
content: `[Broadcast from ${message.from}] ${fencePeerMessage(message.from, message.content)}`,
|
|
614
629
|
display: "collapsed",
|
|
615
630
|
},
|
|
616
631
|
{ deliverAs: "followUp", triggerTurn: false },
|
|
@@ -623,7 +638,7 @@ export function broadcastIrcMessage(fromAgentId: string, message: IrcMessage): s
|
|
|
623
638
|
}
|
|
624
639
|
// Fallback: inject as prompt
|
|
625
640
|
if (typeof handle.session.prompt === "function") {
|
|
626
|
-
const ircPrompt = `[Broadcast from ${message.from}] ${message.content}`;
|
|
641
|
+
const ircPrompt = `[Broadcast from ${message.from}] ${fencePeerMessage(message.from, message.content)}`;
|
|
627
642
|
void handle.session
|
|
628
643
|
.prompt(ircPrompt, {
|
|
629
644
|
source: "api",
|
|
@@ -706,7 +721,7 @@ export async function respondAsBackground(
|
|
|
706
721
|
|
|
707
722
|
// --- Non-blocking delivery -------------------------------------------
|
|
708
723
|
const session = handle.session as Record<string, unknown>;
|
|
709
|
-
const deliveredTag = `[DM from ${fromId}] ${message}`;
|
|
724
|
+
const deliveredTag = `[DM from ${fromId}] ${fencePeerMessage(fromId, message)}`;
|
|
710
725
|
let delivered = false;
|
|
711
726
|
if (typeof session.sendCustomMessage === "function") {
|
|
712
727
|
try {
|
|
@@ -13,7 +13,7 @@ import type { WorkflowStep } from "../../workflows/workflow-config.ts";
|
|
|
13
13
|
import { BoundedTail } from "../compaction/compact-stages/bounded-tail.ts";
|
|
14
14
|
import { createIrcTool } from "../custom-tools/irc-tool.ts";
|
|
15
15
|
import { createSubmitResultTool } from "../custom-tools/submit-result-tool.ts";
|
|
16
|
-
import { g2EnforcementDegradationReason, mcpPermittedForRole, stripMcpExtensions } from "../mcp-proxy.ts";
|
|
16
|
+
import { g2EnforcementDegradationReason, mcpPermittedForRole, shouldFailClosedMcpEnforcement, stripMcpExtensions } from "../mcp-proxy.ts";
|
|
17
17
|
import {
|
|
18
18
|
availableModelInfosFromRegistry,
|
|
19
19
|
buildConfiguredModelRouting,
|
|
@@ -87,7 +87,16 @@ import {
|
|
|
87
87
|
import { hasLiveControlRealtimeListeners, subscribeLiveControlRealtime } from "./live-control-realtime.ts";
|
|
88
88
|
import { buildExtensionBridge } from "./live-extension-bridge.ts";
|
|
89
89
|
import { collectLiveSessionHealth, formatLiveSessionDiagnostics } from "./live-session-health.ts";
|
|
90
|
-
import {
|
|
90
|
+
import {
|
|
91
|
+
asRecord,
|
|
92
|
+
eventText,
|
|
93
|
+
extractToolName,
|
|
94
|
+
faultPathUsage,
|
|
95
|
+
finalAssistantText,
|
|
96
|
+
type SessionStatsLike,
|
|
97
|
+
trackUsageFromMessageEndEvent,
|
|
98
|
+
usageFromSessionStats,
|
|
99
|
+
} from "./usage-bridge.ts";
|
|
91
100
|
|
|
92
101
|
/**
|
|
93
102
|
* Module-scoped latch for the optional peer dependency import. When N
|
|
@@ -435,14 +444,6 @@ function appendTranscript(filePath: string | undefined, event: unknown): void {
|
|
|
435
444
|
appendBatchedJsonlLine(filePath, `${JSON.stringify(redactSecrets(event))}\n`);
|
|
436
445
|
}
|
|
437
446
|
|
|
438
|
-
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
439
|
-
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
function isString(value: unknown): value is string {
|
|
443
|
-
return typeof value === "string";
|
|
444
|
-
}
|
|
445
|
-
|
|
446
447
|
/**
|
|
447
448
|
* U3 test seam: register a caller-supplied pi-ai `Provider` on the created
|
|
448
449
|
* session's ModelRuntime right after creation. Passive by design — no faux
|
|
@@ -463,50 +464,6 @@ function applyModelProviderOverride(session: LiveSessionLike | undefined, provid
|
|
|
463
464
|
}
|
|
464
465
|
}
|
|
465
466
|
|
|
466
|
-
/**
|
|
467
|
-
* Type-safe extractor for tool name from streaming events.
|
|
468
|
-
* Handles various event structures: { tool: { name } }, { toolName }, { name }.
|
|
469
|
-
*/
|
|
470
|
-
function extractToolName(obj: Record<string, unknown> | undefined): string | undefined {
|
|
471
|
-
if (!obj) return undefined;
|
|
472
|
-
const tool = asRecord(obj.tool);
|
|
473
|
-
if (isString(tool?.name)) return tool.name;
|
|
474
|
-
if (isString(obj.toolName)) return obj.toolName;
|
|
475
|
-
if (isString(obj.name)) return obj.name;
|
|
476
|
-
return undefined;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
function textFromContent(content: unknown): string[] {
|
|
480
|
-
if (typeof content === "string") return [content];
|
|
481
|
-
if (!Array.isArray(content)) return [];
|
|
482
|
-
return content.flatMap((part) => {
|
|
483
|
-
const obj = asRecord(part);
|
|
484
|
-
if (!obj) return [];
|
|
485
|
-
if (obj.type === "text" && typeof obj.text === "string") return [obj.text];
|
|
486
|
-
if (typeof obj.content === "string") return [obj.content];
|
|
487
|
-
return [];
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function eventText(event: unknown): string[] {
|
|
492
|
-
const obj = asRecord(event);
|
|
493
|
-
if (!obj) return [];
|
|
494
|
-
const text: string[] = [];
|
|
495
|
-
if (typeof obj.text === "string") text.push(obj.text);
|
|
496
|
-
text.push(...textFromContent(obj.content));
|
|
497
|
-
const message = asRecord(obj.message);
|
|
498
|
-
if (message) text.push(...textFromContent(message.content));
|
|
499
|
-
return text.filter((entry) => entry.trim());
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
function finalAssistantText(event: unknown): string[] {
|
|
503
|
-
const obj = asRecord(event);
|
|
504
|
-
if (obj?.type !== "message_end") return [];
|
|
505
|
-
const message = asRecord(obj.message);
|
|
506
|
-
if (message?.role !== "assistant") return [];
|
|
507
|
-
return textFromContent(message.content);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
467
|
/**
|
|
511
468
|
* F7: resolve the enabledModels allowlist for the current project, but only
|
|
512
469
|
* if the `reliability.scopeModels` toggle is ON. Returns an empty
|
|
@@ -892,25 +849,6 @@ class BoundedSeenControlRequestIdSet extends Set<string> {
|
|
|
892
849
|
/** @internal — test seam for the C1 cap (see live-agent-control-dedupe tests). */
|
|
893
850
|
export const __test__BoundedSeenControlRequestIdSet = BoundedSeenControlRequestIdSet;
|
|
894
851
|
|
|
895
|
-
/**
|
|
896
|
-
* R6-9 (DR-03 round-6): best-effort usage extraction for the fault/watchdog
|
|
897
|
-
* escape returns. The success return reports usageFromSessionStats(session),
|
|
898
|
-
* but the fault/watchdog catch paths previously carried NO usage — whenever
|
|
899
|
-
* embeddedWorkers routed work to the embedded tier and the task faulted, the
|
|
900
|
-
* burned turns vanished from the budget (under-count). The session may be
|
|
901
|
-
* disposed or partially unusable after the fault, so stats collection is
|
|
902
|
-
* guarded — it can never turn a fault into a crash. Returns undefined when no
|
|
903
|
-
* usable stats surface exists.
|
|
904
|
-
*/
|
|
905
|
-
function faultPathUsage(session: LiveSessionLike | undefined): UsageState | undefined {
|
|
906
|
-
try {
|
|
907
|
-
return usageFromSessionStats(session);
|
|
908
|
-
} catch (error) {
|
|
909
|
-
logInternalError("live-session.fault-usage-stats", error, "session stats unavailable on fault path");
|
|
910
|
-
return undefined;
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
|
|
914
852
|
export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<LiveSessionRunResult> {
|
|
915
853
|
// Cold-start race fix: ensure the hot module graph is warm before touching
|
|
916
854
|
// any module. Under tsx, concurrent first-imports race module-record
|
|
@@ -944,7 +882,13 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
944
882
|
// G1: Capture yield result from custom tool callback
|
|
945
883
|
let customToolYieldResult: YieldResult | undefined;
|
|
946
884
|
let customToolYieldResolved = false;
|
|
947
|
-
|
|
885
|
+
// wave-2b (mock double-gate): the mock session fabricates a success event
|
|
886
|
+
// for the whole run — single-gated, inherited env would silently neuter a
|
|
887
|
+
// worker. Double-gate with PI_CREW_ALLOW_MOCK (mirror mock-fixtures.ts).
|
|
888
|
+
if (
|
|
889
|
+
getCrewEnv("PI_CREW_MOCK_LIVE_SESSION") === "success" &&
|
|
890
|
+
(getCrewEnv("PI_CREW_ALLOW_MOCK") === "1" || getCrewEnv("PI_CREW_ALLOW_MOCK") === "true")
|
|
891
|
+
) {
|
|
948
892
|
const agentId = `${input.manifest.runId}:${input.task.id}`;
|
|
949
893
|
const inherited =
|
|
950
894
|
input.runtimeConfig?.inheritContext === true && input.parentContext ? ` with inherited context: ${input.parentContext}` : "";
|
|
@@ -1207,16 +1151,36 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1207
1151
|
resourceLoaderAvailable: Boolean(mod.DefaultResourceLoader && agentDir),
|
|
1208
1152
|
});
|
|
1209
1153
|
if (g2DegradationReason) {
|
|
1154
|
+
// R1-M2 part 2 (Wave-3): FAIL CLOSED. Previously the degradation only
|
|
1155
|
+
// warned and proceeded — a version-drifted SDK silently re-exposed the
|
|
1156
|
+
// parent's MCP servers to a NON-PERMITTED (read-only) role. MCP-permitted
|
|
1157
|
+
// roles keep the warn+proceed behavior (their access is intended); every
|
|
1158
|
+
// other role ABORTS the spawn with a typed error so the drift is loud.
|
|
1159
|
+
const failClosedCode = "mcp-enforcement-fail-closed";
|
|
1210
1160
|
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
1211
|
-
type: "task.mcp_enforcement_degraded",
|
|
1161
|
+
type: mcpPermitted ? "task.mcp_enforcement_degraded" : "task.mcp_enforcement_fail_closed",
|
|
1212
1162
|
runId: input.manifest.runId,
|
|
1213
1163
|
taskId: input.task.id,
|
|
1214
|
-
message:
|
|
1164
|
+
message: mcpPermitted
|
|
1165
|
+
? `G2 MCP policy NOT enforced for role "${input.task.role ?? "unknown"}": pi SDK resource loader unavailable (${g2DegradationReason}) — this worker may inherit the parent's MCP servers.`
|
|
1166
|
+
: `G2 MCP policy ABORTED live-session spawn for role "${input.task.role ?? "unknown"}" (${failClosedCode}): enforcement (MCP extension stripping) could not be armed (${g2DegradationReason}) — refusing fail-open inheritance of the parent's MCP servers.`,
|
|
1215
1167
|
data: {
|
|
1216
1168
|
role: input.task.role ?? null,
|
|
1217
1169
|
reason: g2DegradationReason,
|
|
1170
|
+
code: failClosedCode,
|
|
1218
1171
|
},
|
|
1219
1172
|
});
|
|
1173
|
+
if (g2DegradationReason && shouldFailClosedMcpEnforcement(mcpPermitted, g2DegradationReason)) {
|
|
1174
|
+
const message = `[${failClosedCode}] Live-session spawn refused for role "${input.task.role ?? "unknown"}": MCP enforcement degraded (${g2DegradationReason}) and the role is not MCP-permitted — fail-closed instead of inheriting the parent's MCP servers.`;
|
|
1175
|
+
return {
|
|
1176
|
+
available: true,
|
|
1177
|
+
exitCode: 1,
|
|
1178
|
+
stdout: "",
|
|
1179
|
+
stderr: message,
|
|
1180
|
+
jsonEvents: 0,
|
|
1181
|
+
error: message,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1220
1184
|
}
|
|
1221
1185
|
const effectiveParentModel = resolveParentModelFromRegistry(input.modelRegistry, input.parentModel);
|
|
1222
1186
|
const modelRouting = buildConfiguredModelRouting({
|
|
@@ -1471,12 +1435,16 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1471
1435
|
};
|
|
1472
1436
|
unsubscribeControlRealtime = subscribeLiveControlRealtime((request) => {
|
|
1473
1437
|
if (!isCurrent() || request.runId !== input.manifest.runId || request.taskId !== input.task.id || !session) return;
|
|
1438
|
+
// R3A-M1: the applier itself now records failures on the request, but a
|
|
1439
|
+
// rejection must never reach unhandledRejection — catch + log here too.
|
|
1474
1440
|
void applyLiveAgentControlRequest({
|
|
1475
1441
|
request,
|
|
1476
1442
|
taskId: input.task.id,
|
|
1477
1443
|
agentId,
|
|
1478
1444
|
session,
|
|
1479
1445
|
seenRequestIds: seenControlRequestIds,
|
|
1446
|
+
}).catch((error) => {
|
|
1447
|
+
logInternalError("live-session.control-realtime-apply", error, `agentId=${agentId}`);
|
|
1480
1448
|
});
|
|
1481
1449
|
});
|
|
1482
1450
|
await pollControl();
|
|
@@ -1488,7 +1456,11 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1488
1456
|
// path → keep the faster 500ms cadence.
|
|
1489
1457
|
controlTimer = setInterval(
|
|
1490
1458
|
() => {
|
|
1491
|
-
|
|
1459
|
+
// R3A-M1: fire-and-forget poll must never reject unhandled.
|
|
1460
|
+
if (isCurrent())
|
|
1461
|
+
void pollControl().catch((error) => {
|
|
1462
|
+
logInternalError("live-session.control-poll", error, `agentId=${agentId}`);
|
|
1463
|
+
});
|
|
1492
1464
|
},
|
|
1493
1465
|
hasLiveControlRealtimeListeners() ? 1000 : 500,
|
|
1494
1466
|
);
|
|
@@ -1525,14 +1497,21 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1525
1497
|
trackLiveAgentTurnEnd(agentId);
|
|
1526
1498
|
if (maxTurns !== undefined && !softLimitReached && turnCount >= maxTurns) {
|
|
1527
1499
|
softLimitReached = true;
|
|
1528
|
-
|
|
1500
|
+
// R3A-M1: rejection here would crash the host session.
|
|
1501
|
+
void session
|
|
1502
|
+
?.steer?.("You have reached your turn limit. Wrap up immediately — provide your final answer now.")
|
|
1503
|
+
.catch((error) => {
|
|
1504
|
+
logInternalError("live-session.turn-limit-steer", error, `agentId=${agentId}`);
|
|
1505
|
+
});
|
|
1529
1506
|
} else if (maxTurns !== undefined && softLimitReached && turnCount >= maxTurns + graceTurns) {
|
|
1530
1507
|
// U11 mitigation (c): abort + bounded watchdog — if the prompt does
|
|
1531
1508
|
// not settle within the grace, dispose the session (drop-reference,
|
|
1532
1509
|
// U4 escape discipline) and fail via the escape signal.
|
|
1533
1510
|
if (embedded && !embeddedWatchdogFired) {
|
|
1534
1511
|
embeddedWatchdogFired = true;
|
|
1535
|
-
void session?.abort?.()
|
|
1512
|
+
void Promise.resolve(session?.abort?.()).catch((error: unknown) => {
|
|
1513
|
+
logInternalError("live-session.watchdog-abort", error, `agentId=${agentId}`);
|
|
1514
|
+
});
|
|
1536
1515
|
embeddedWatchdogTimer = armTurnWatchdog({
|
|
1537
1516
|
agentId,
|
|
1538
1517
|
maxTurns,
|
|
@@ -1542,7 +1521,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1542
1521
|
escalate: (error: Error) => faultSignalReject?.(error),
|
|
1543
1522
|
});
|
|
1544
1523
|
} else {
|
|
1545
|
-
void session?.abort?.()
|
|
1524
|
+
void Promise.resolve(session?.abort?.()).catch((error: unknown) => {
|
|
1525
|
+
logInternalError("live-session.watchdog-abort", error, `agentId=${agentId}`);
|
|
1526
|
+
});
|
|
1546
1527
|
}
|
|
1547
1528
|
}
|
|
1548
1529
|
}
|
|
@@ -1581,7 +1562,11 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1581
1562
|
}
|
|
1582
1563
|
// Round 27 (BUG 4): named abort handler (removed in finally below).
|
|
1583
1564
|
onSignalAbort = (): void => {
|
|
1584
|
-
|
|
1565
|
+
// R3A-M1: abort rejection inside a signal handler must not become an
|
|
1566
|
+
// unhandledRejection — catch + log.
|
|
1567
|
+
void Promise.resolve(session?.abort?.()).catch((error: unknown) => {
|
|
1568
|
+
logInternalError("live-session.signal-abort", error, `agentId=${agentId}`);
|
|
1569
|
+
});
|
|
1585
1570
|
};
|
|
1586
1571
|
if (input.signal) {
|
|
1587
1572
|
if (input.signal.aborted) await session.abort?.();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { UsageState } from "../../state/types.ts";
|
|
2
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
2
3
|
import { usageFromValue } from "../session-usage.ts";
|
|
3
4
|
import { trackTaskUsage } from "../usage-tracker.ts";
|
|
4
5
|
|
|
@@ -9,11 +10,11 @@ import { trackTaskUsage } from "../usage-tracker.ts";
|
|
|
9
10
|
* ledger — the runtime module keeps only the call sites.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
|
-
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
13
|
+
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
13
14
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
function isString(value: unknown): value is string {
|
|
17
|
+
export function isString(value: unknown): value is string {
|
|
17
18
|
return typeof value === "string";
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -91,3 +92,66 @@ export function trackUsageFromMessageEndEvent(taskId: string, event: unknown): v
|
|
|
91
92
|
cost: usage.cost,
|
|
92
93
|
});
|
|
93
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* R6-9 (DR-03 round-6): best-effort usage extraction for the fault/watchdog
|
|
98
|
+
* escape returns. The success return reports usageFromSessionStats(session as SessionWithStats | undefined),
|
|
99
|
+
* but the fault/watchdog catch paths previously carried NO usage — whenever
|
|
100
|
+
* embeddedWorkers routed work to the embedded tier and the task faulted, the
|
|
101
|
+
* burned turns vanished from the budget (under-count). The session may be
|
|
102
|
+
* disposed or partially unusable after the fault, so stats collection is
|
|
103
|
+
* guarded — it can never turn a fault into a crash. Returns undefined when no
|
|
104
|
+
* usable stats surface exists.
|
|
105
|
+
*/
|
|
106
|
+
export function faultPathUsage(session: SessionWithStats | undefined): UsageState | undefined {
|
|
107
|
+
try {
|
|
108
|
+
return usageFromSessionStats(session as SessionWithStats | undefined);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
logInternalError("live-session.fault-usage-stats", error, "session stats unavailable on fault path");
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Type-safe extractor for tool name from streaming events.
|
|
117
|
+
* Handles various event structures: { tool: { name } }, { toolName }, { name }.
|
|
118
|
+
*/
|
|
119
|
+
export function extractToolName(obj: Record<string, unknown> | undefined): string | undefined {
|
|
120
|
+
if (!obj) return undefined;
|
|
121
|
+
const tool = asRecord(obj.tool);
|
|
122
|
+
if (isString(tool?.name)) return tool.name;
|
|
123
|
+
if (isString(obj.toolName)) return obj.toolName;
|
|
124
|
+
if (isString(obj.name)) return obj.name;
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function textFromContent(content: unknown): string[] {
|
|
129
|
+
if (typeof content === "string") return [content];
|
|
130
|
+
if (!Array.isArray(content)) return [];
|
|
131
|
+
return content.flatMap((part) => {
|
|
132
|
+
const obj = asRecord(part);
|
|
133
|
+
if (!obj) return [];
|
|
134
|
+
if (obj.type === "text" && typeof obj.text === "string") return [obj.text];
|
|
135
|
+
if (typeof obj.content === "string") return [obj.content];
|
|
136
|
+
return [];
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function eventText(event: unknown): string[] {
|
|
141
|
+
const obj = asRecord(event);
|
|
142
|
+
if (!obj) return [];
|
|
143
|
+
const text: string[] = [];
|
|
144
|
+
if (typeof obj.text === "string") text.push(obj.text);
|
|
145
|
+
text.push(...textFromContent(obj.content));
|
|
146
|
+
const message = asRecord(obj.message);
|
|
147
|
+
if (message) text.push(...textFromContent(message.content));
|
|
148
|
+
return text.filter((entry) => entry.trim());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function finalAssistantText(event: unknown): string[] {
|
|
152
|
+
const obj = asRecord(event);
|
|
153
|
+
if (obj?.type !== "message_end") return [];
|
|
154
|
+
const message = asRecord(obj.message);
|
|
155
|
+
if (message?.role !== "assistant") return [];
|
|
156
|
+
return textFromContent(message.content);
|
|
157
|
+
}
|
package/src/runtime/mcp-proxy.ts
CHANGED
|
@@ -206,3 +206,13 @@ export function stripMcpExtensions(base: LoadedExtensionsShape): LoadedExtension
|
|
|
206
206
|
],
|
|
207
207
|
};
|
|
208
208
|
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* R1-M2 part 2 (Wave-3): pure fail-closed decision — when G2 MCP enforcement
|
|
212
|
+
* could not be armed (SDK loader missing / degraded), a NON-PERMITTED role
|
|
213
|
+
* must ABORT the live-session spawn instead of warn+proceed (fail-open);
|
|
214
|
+
* permitted roles keep the warn+proceed behavior (their access is intended).
|
|
215
|
+
*/
|
|
216
|
+
export function shouldFailClosedMcpEnforcement(mcpPermitted: boolean, degradationReason: string | undefined): boolean {
|
|
217
|
+
return degradationReason !== undefined && !mcpPermitted;
|
|
218
|
+
}
|
|
@@ -444,13 +444,81 @@ const NON_RETRYABLE_MODEL_FAILURE_PATTERNS = [
|
|
|
444
444
|
/credit/i,
|
|
445
445
|
];
|
|
446
446
|
|
|
447
|
+
// R2-M-B2 (Wave-3): provider SAFETY/REFUSAL stop reasons must NOT be retried
|
|
448
|
+
// down the fallback chain ("safety-refusal laundering" — re-dispatching the
|
|
449
|
+
// same refused prompt to other models). The STRUCTURED stop reason is the
|
|
450
|
+
// authoritative signal; the legacy `/safety/i` substring in
|
|
451
|
+
// RETRYABLE_MODEL_FAILURE_PATTERNS stays as a FALLBACK only for errors that
|
|
452
|
+
// carry no structured marker (legacy serialization).
|
|
453
|
+
export const SAFETY_REFUSAL_STOP_REASONS: ReadonlySet<string> = new Set([
|
|
454
|
+
"refusal",
|
|
455
|
+
"refused",
|
|
456
|
+
"safety",
|
|
457
|
+
"safety_block",
|
|
458
|
+
"safety_block_consumed",
|
|
459
|
+
"content_policy",
|
|
460
|
+
"content_policy_violation",
|
|
461
|
+
"content_filter",
|
|
462
|
+
]);
|
|
463
|
+
|
|
464
|
+
/** Normalize + match a stop reason (or stopReason/stop_reason value) against the refusal/safety set. */
|
|
465
|
+
export function isSafetyRefusalStopReason(reason: unknown): boolean {
|
|
466
|
+
if (typeof reason !== "string") return false;
|
|
467
|
+
return SAFETY_REFUSAL_STOP_REASONS.has(reason.trim().toLowerCase());
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const SERIALIZED_STOP_REASON_MARKER = /(?:stop_reason|stopReason|stop reason)["':=\s]+["']?([a-z_\- ]+)/i;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Structured safety-refusal detection on an ERROR OBJECT (preferred) or a
|
|
474
|
+
* serialized error string: true when the error carries a stop_reason in the
|
|
475
|
+
* refusal/safety family. Object shape: `{ stopReason }`, `{ stop_reason }`,
|
|
476
|
+
* nested under `.error`/`.cause`, or the same marker serialized into a string
|
|
477
|
+
* (JSON.stringify'd errors, stderr tails).
|
|
478
|
+
*/
|
|
479
|
+
export function hasStructuredSafetyRefusal(errorLike: unknown): boolean {
|
|
480
|
+
if (!errorLike) return false;
|
|
481
|
+
if (typeof errorLike === "string") {
|
|
482
|
+
const match = SERIALIZED_STOP_REASON_MARKER.exec(errorLike);
|
|
483
|
+
return match ? isSafetyRefusalStopReason(match[1]) : false;
|
|
484
|
+
}
|
|
485
|
+
if (typeof errorLike !== "object") return false;
|
|
486
|
+
const obj = errorLike as Record<string, unknown>;
|
|
487
|
+
if (isSafetyRefusalStopReason(obj.stopReason)) return true;
|
|
488
|
+
if (isSafetyRefusalStopReason(obj.stop_reason)) return true;
|
|
489
|
+
if (obj.error !== undefined && obj.error !== obj && hasStructuredSafetyRefusal(obj.error)) return true;
|
|
490
|
+
if (obj.cause !== undefined && obj.cause !== obj && hasStructuredSafetyRefusal(obj.cause)) return true;
|
|
491
|
+
// Error instances: message may carry the serialized marker.
|
|
492
|
+
if (typeof obj.message === "string") {
|
|
493
|
+
const match = SERIALIZED_STOP_REASON_MARKER.exec(obj.message);
|
|
494
|
+
if (match && isSafetyRefusalStopReason(match[1])) return true;
|
|
495
|
+
}
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
|
|
447
499
|
export function isRetryableModelFailure(error: string | undefined): boolean {
|
|
448
500
|
if (!error) return false;
|
|
501
|
+
// R2-M-B2 (Wave-3): structured safety/refusal stop reasons are checked
|
|
502
|
+
// FIRST — non-retryable even when the message text otherwise matches
|
|
503
|
+
// retryable-looking patterns ("overloaded", "transient", …). The plain
|
|
504
|
+
// `/safety/i` substring below remains as the LEGACY fallback for errors
|
|
505
|
+
// without a structured marker.
|
|
506
|
+
if (hasStructuredSafetyRefusal(error)) return false;
|
|
449
507
|
// Auth / billing / invalid-key failures will never succeed on retry.
|
|
450
508
|
if (NON_RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error))) return false;
|
|
451
509
|
return RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error));
|
|
452
510
|
}
|
|
453
511
|
|
|
512
|
+
/**
|
|
513
|
+
* R2-M-B2 companion for call sites that still hold the structured CAUSE
|
|
514
|
+
* object: consult it FIRST (authoritative), then fall back to the string
|
|
515
|
+
* classification of `message`.
|
|
516
|
+
*/
|
|
517
|
+
export function isRetryableModelFailureFrom(cause: unknown, message?: string): boolean {
|
|
518
|
+
if (hasStructuredSafetyRefusal(cause)) return false;
|
|
519
|
+
return isRetryableModelFailure(message ?? (cause instanceof Error ? cause.message : undefined));
|
|
520
|
+
}
|
|
521
|
+
|
|
454
522
|
export function formatModelAttemptNote(attempt: ModelAttemptSummary, nextModel?: string): string {
|
|
455
523
|
const failure = attempt.error?.trim() || `exit ${attempt.exitCode ?? 1}`;
|
|
456
524
|
return nextModel
|
|
@@ -40,6 +40,21 @@ export function resolveEmbeddedFaultThreshold(policy: CrewRuntimeConfig["embedde
|
|
|
40
40
|
* (the same depth>0 rule is applied to the embedded upgrade itself).
|
|
41
41
|
* - Otherwise, use `isolationPolicy.defaultRuntime` when configured, then fall back to globalKind.
|
|
42
42
|
*/
|
|
43
|
+
/**
|
|
44
|
+
* wave-2b (mock double-gate): PI_CREW_MOCK_LIVE_SESSION is a test seam that
|
|
45
|
+
* flips production runtime selection (policy + resolver) and fabricates a
|
|
46
|
+
* mock session — single-gated, an env var inherited from a hostile parent
|
|
47
|
+
* would silently downgrade a worker to a mock. Mirror the existing
|
|
48
|
+
* double-gated seams (PI_CREW_TEST_ASYNC_INLINE + PI_CREW_ALLOW_MOCK in
|
|
49
|
+
* async-runner.ts; PI_TEAMS_MOCK_CHILD_PI + PI_CREW_ALLOW_MOCK in
|
|
50
|
+
* mock-fixtures.ts): the mock only engages when PI_CREW_ALLOW_MOCK is ALSO
|
|
51
|
+
* set, and PI_CREW_ALLOW_MOCK is filtered from child envs.
|
|
52
|
+
*/
|
|
53
|
+
export function mockLiveSessionEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
54
|
+
if (env.PI_CREW_MOCK_LIVE_SESSION !== "success") return false;
|
|
55
|
+
return env.PI_CREW_ALLOW_MOCK === "1" || env.PI_CREW_ALLOW_MOCK === "true";
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
export function resolveTaskRuntimeKind(
|
|
44
59
|
globalKind: CrewRuntimeKind,
|
|
45
60
|
role: string,
|
|
@@ -51,22 +66,21 @@ export function resolveTaskRuntimeKind(
|
|
|
51
66
|
// Safety: when already inside a pi-crew worker (depth > 0), never nest live-session.
|
|
52
67
|
// Live-session creates in-process Pi agent sessions, which would recursively
|
|
53
68
|
// try to use pi-crew, leading to "Cannot read properties of undefined" errors.
|
|
54
|
-
// Exception:
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
if (globalKind === "live-session" && currentCrewDepth(env) > 0 && env.PI_CREW_MOCK_LIVE_SESSION !== "success") return "child-process";
|
|
69
|
+
// Exception: the double-gated mock seam (mockLiveSessionEnabled) — forcing
|
|
70
|
+
// child-process would spawn a real pi process and hang the test harness.
|
|
71
|
+
if (globalKind === "live-session" && currentCrewDepth(env) > 0 && !mockLiveSessionEnabled(env)) return "child-process";
|
|
58
72
|
const isolatedRoles = isolationPolicy?.isolatedRoles ?? [];
|
|
59
73
|
if (isolatedRoles.includes(role)) return "child-process";
|
|
60
74
|
// U11 embedded-tier upgrade (DEFAULT-OFF). Guards mirror the depth guard
|
|
61
75
|
// above: an in-process embedded session has the SAME nesting hazard, so
|
|
62
76
|
// depth>0 keeps the task on child-process regardless of which kind the
|
|
63
77
|
// caller resolved — the guard is EXTENDED to the embedded path, never
|
|
64
|
-
// weakened (
|
|
78
|
+
// weakened (the double-gated mock seam keeps its test-harness exception).
|
|
65
79
|
if (
|
|
66
80
|
embedded?.policy?.enabled === true &&
|
|
67
81
|
(embedded.policy.roles ?? []).includes(role) &&
|
|
68
82
|
(embedded.faultCount ?? 0) < resolveEmbeddedFaultThreshold(embedded.policy) &&
|
|
69
|
-
(currentCrewDepth(env) <= 0 || env
|
|
83
|
+
(currentCrewDepth(env) <= 0 || mockLiveSessionEnabled(env))
|
|
70
84
|
) {
|
|
71
85
|
return "live-session";
|
|
72
86
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PiTeamsConfig } from "../../config/config.ts";
|
|
2
2
|
import type { RuntimeResolutionState } from "../../state/types.ts";
|
|
3
3
|
import type { CrewRuntimeKind } from "../crew-agent-runtime.ts";
|
|
4
|
+
import { mockLiveSessionEnabled } from "./runtime-policy.ts";
|
|
4
5
|
|
|
5
6
|
export type CrewRuntimeMode = "auto" | "scaffold" | "child-process" | "live-session";
|
|
6
7
|
|
|
@@ -35,7 +36,8 @@ export async function isLiveSessionRuntimeAvailable(
|
|
|
35
36
|
timeoutMs = 1500,
|
|
36
37
|
env: NodeJS.ProcessEnv = process.env,
|
|
37
38
|
): Promise<{ available: boolean; reason?: string }> {
|
|
38
|
-
|
|
39
|
+
// wave-2b: double-gated mock seam — see mockLiveSessionEnabled.
|
|
40
|
+
if (mockLiveSessionEnabled(env)) {
|
|
39
41
|
return {
|
|
40
42
|
available: true,
|
|
41
43
|
reason: "Mock live-session runtime is enabled.",
|
|
@@ -112,8 +114,8 @@ export async function resolveCrewRuntime(config: PiTeamsConfig, env: NodeJS.Proc
|
|
|
112
114
|
}
|
|
113
115
|
// auto mode: use child-process unless preferLiveSession is explicitly enabled
|
|
114
116
|
if (requestedMode === "auto") {
|
|
115
|
-
// Check for mock
|
|
116
|
-
if (env
|
|
117
|
+
// Check for the double-gated mock seam first (for testing)
|
|
118
|
+
if (mockLiveSessionEnabled(env)) {
|
|
117
119
|
const live = await isLiveSessionRuntimeAvailable(1500, env);
|
|
118
120
|
if (live.available) return liveCaps(requestedMode);
|
|
119
121
|
}
|