@stigmer/runner 3.0.8-dev.20260613085218 → 3.0.9-dev.20260615145121
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/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/hook-script.d.ts +23 -12
- package/dist/activities/execute-cursor/hook-script.js +85 -51
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +220 -79
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +51 -9
- package/dist/activities/execute-cursor/message-translator.js +146 -20
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/persist-decision.d.ts +42 -0
- package/dist/activities/execute-cursor/persist-decision.js +30 -0
- package/dist/activities/execute-cursor/persist-decision.js.map +1 -0
- package/dist/activities/execute-cursor/prompt-builder.d.ts +25 -0
- package/dist/activities/execute-cursor/prompt-builder.js +54 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-setup.d.ts +8 -2
- package/dist/activities/execute-cursor/workspace-setup.js +62 -30
- package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +15 -5
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +0 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.js +32 -8
- package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +4 -5
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js +4 -5
- package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +9 -1
- package/dist/activities/execute-deep-agent/streaming.js +4 -5
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-tracker.js +4 -5
- package/dist/activities/execute-deep-agent/subagent-tracker.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -5
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/in-flight.d.ts +35 -0
- package/dist/in-flight.js +61 -0
- package/dist/in-flight.js.map +1 -0
- package/dist/runner-manager.d.ts +2 -0
- package/dist/runner-manager.js +90 -29
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +2 -0
- package/dist/runner.js.map +1 -1
- package/dist/shared/grpc-retry.d.ts +9 -20
- package/dist/shared/grpc-retry.js +9 -52
- package/dist/shared/grpc-retry.js.map +1 -1
- package/dist/shared/stall-watchdog.d.ts +68 -0
- package/dist/shared/stall-watchdog.js +102 -0
- package/dist/shared/stall-watchdog.js.map +1 -0
- package/dist/shared/status-offload.d.ts +84 -0
- package/dist/shared/status-offload.js +292 -0
- package/dist/shared/status-offload.js.map +1 -0
- package/dist/shared/status.d.ts +34 -3
- package/dist/shared/status.js +102 -9
- package/dist/shared/status.js.map +1 -1
- package/dist/{activities/execute-deep-agent → shared}/streaming-scheduler.d.ts +4 -0
- package/dist/{activities/execute-deep-agent → shared}/streaming-scheduler.js +4 -0
- package/dist/shared/streaming-scheduler.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/config.test.ts +8 -0
- package/src/__tests__/in-flight.test.ts +84 -0
- package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +74 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +90 -15
- package/src/activities/execute-cursor/__tests__/message-translator.test.ts +124 -12
- package/src/activities/execute-cursor/__tests__/persist-decision.test.ts +99 -0
- package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +244 -0
- package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +53 -4
- package/src/activities/execute-cursor/hook-script.ts +85 -51
- package/src/activities/execute-cursor/index.ts +187 -38
- package/src/activities/execute-cursor/message-translator.ts +146 -20
- package/src/activities/execute-cursor/persist-decision.ts +54 -0
- package/src/activities/execute-cursor/prompt-builder.ts +59 -0
- package/src/activities/execute-cursor/workspace-setup.ts +76 -44
- package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder-shared.test.ts +66 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +6 -3
- package/src/activities/execute-deep-agent/__tests__/streaming-v3.test.ts +70 -0
- package/src/activities/execute-deep-agent/index.ts +17 -5
- package/src/activities/execute-deep-agent/status-builder-shared.ts +27 -5
- package/src/activities/execute-deep-agent/status-builder.ts +3 -5
- package/src/activities/execute-deep-agent/streaming-v3.ts +5 -5
- package/src/activities/execute-deep-agent/streaming.ts +14 -5
- package/src/activities/execute-deep-agent/subagent-tracker.ts +4 -5
- package/src/activities/execute-deep-agent/v3-status-builder.ts +5 -5
- package/src/config.ts +27 -0
- package/src/in-flight.ts +71 -0
- package/src/runner-manager.ts +127 -33
- package/src/runner.ts +6 -0
- package/src/shared/__tests__/artifact-storage.test.ts +1 -0
- package/src/shared/__tests__/grpc-retry-extended.test.ts +6 -144
- package/src/shared/__tests__/grpc-retry.test.ts +5 -134
- package/src/shared/__tests__/stall-watchdog.test.ts +193 -0
- package/src/shared/__tests__/status-offload.test.ts +256 -0
- package/src/shared/__tests__/status.test.ts +199 -0
- package/src/shared/grpc-retry.ts +9 -72
- package/src/shared/stall-watchdog.ts +122 -0
- package/src/shared/status-offload.ts +342 -0
- package/src/shared/status.ts +142 -8
- package/src/{activities/execute-deep-agent → shared}/streaming-scheduler.ts +4 -0
- package/dist/activities/execute-deep-agent/streaming-scheduler.js.map +0 -1
- /package/src/{activities/execute-deep-agent → shared}/__tests__/streaming-scheduler.test.ts +0 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
startStallWatchdog,
|
|
4
|
+
StallTimeoutError,
|
|
5
|
+
formatStallFailure,
|
|
6
|
+
STALL_ERROR_PREFIX,
|
|
7
|
+
DEFAULT_STALL_TIMEOUT_MS,
|
|
8
|
+
} from "../stall-watchdog.js";
|
|
9
|
+
|
|
10
|
+
describe("startStallWatchdog", () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.useFakeTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
vi.useRealTimers();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("fires onStall once after the idle window elapses with no activity", () => {
|
|
21
|
+
const onStall = vi.fn();
|
|
22
|
+
const wd = startStallWatchdog(60_000, onStall);
|
|
23
|
+
|
|
24
|
+
// Just before the window: not yet fired.
|
|
25
|
+
vi.advanceTimersByTime(59_000);
|
|
26
|
+
expect(onStall).not.toHaveBeenCalled();
|
|
27
|
+
|
|
28
|
+
// Cross the window: fires exactly once.
|
|
29
|
+
vi.advanceTimersByTime(2_000);
|
|
30
|
+
expect(onStall).toHaveBeenCalledTimes(1);
|
|
31
|
+
expect(onStall.mock.calls[0][0]).toBeGreaterThanOrEqual(60_000);
|
|
32
|
+
|
|
33
|
+
// It self-disarms — no further fires even as time marches on.
|
|
34
|
+
vi.advanceTimersByTime(120_000);
|
|
35
|
+
expect(onStall).toHaveBeenCalledTimes(1);
|
|
36
|
+
|
|
37
|
+
wd.stop();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("recordActivity resets the idle timer so progress keeps it alive", () => {
|
|
41
|
+
const onStall = vi.fn();
|
|
42
|
+
const wd = startStallWatchdog(60_000, onStall);
|
|
43
|
+
|
|
44
|
+
// Report progress every 40s for a while: the window never elapses.
|
|
45
|
+
for (let i = 0; i < 10; i++) {
|
|
46
|
+
vi.advanceTimersByTime(40_000);
|
|
47
|
+
wd.recordActivity();
|
|
48
|
+
}
|
|
49
|
+
expect(onStall).not.toHaveBeenCalled();
|
|
50
|
+
|
|
51
|
+
// Now go quiet past the window. Detection has up to one poll interval
|
|
52
|
+
// (stallMs/4, capped 15s) of slop because ticks fire on absolute
|
|
53
|
+
// boundaries, so advance stallMs + a full tick to guarantee a fire.
|
|
54
|
+
vi.advanceTimersByTime(76_000);
|
|
55
|
+
expect(onStall).toHaveBeenCalledTimes(1);
|
|
56
|
+
|
|
57
|
+
wd.stop();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("stop() disarms the watchdog so onStall never fires", () => {
|
|
61
|
+
const onStall = vi.fn();
|
|
62
|
+
const wd = startStallWatchdog(60_000, onStall);
|
|
63
|
+
|
|
64
|
+
wd.stop();
|
|
65
|
+
vi.advanceTimersByTime(600_000);
|
|
66
|
+
expect(onStall).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("is robust to stop() called after firing (idempotent)", () => {
|
|
70
|
+
const onStall = vi.fn();
|
|
71
|
+
const wd = startStallWatchdog(30_000, onStall);
|
|
72
|
+
|
|
73
|
+
vi.advanceTimersByTime(31_000);
|
|
74
|
+
expect(onStall).toHaveBeenCalledTimes(1);
|
|
75
|
+
|
|
76
|
+
expect(() => wd.stop()).not.toThrow();
|
|
77
|
+
vi.advanceTimersByTime(60_000);
|
|
78
|
+
expect(onStall).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("StallTimeoutError", () => {
|
|
83
|
+
it("builds a readable message from the idle duration", () => {
|
|
84
|
+
const err = new StallTimeoutError(120_000);
|
|
85
|
+
expect(err.name).toBe("StallTimeoutError");
|
|
86
|
+
expect(err.stalledMs).toBe(120_000);
|
|
87
|
+
expect(err.message).toContain("120s");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("appends an optional detail", () => {
|
|
91
|
+
const err = new StallTimeoutError(90_000, "last tool: browser_capture");
|
|
92
|
+
expect(err.message).toContain("90s");
|
|
93
|
+
expect(err.message).toContain("last tool: browser_capture");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("formatStallFailure", () => {
|
|
98
|
+
it("prefixes the recognizable tag and appends actionable guidance", () => {
|
|
99
|
+
const msg = formatStallFailure(new StallTimeoutError(180_000, "last tool: browser_capture"));
|
|
100
|
+
expect(msg.startsWith(STALL_ERROR_PREFIX)).toBe(true);
|
|
101
|
+
expect(msg).toContain("180s");
|
|
102
|
+
expect(msg).toContain("last tool: browser_capture");
|
|
103
|
+
expect(msg).toContain("Retry or resume.");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("DEFAULT_STALL_TIMEOUT_MS", () => {
|
|
108
|
+
it("matches the canonical 120s default", () => {
|
|
109
|
+
expect(DEFAULT_STALL_TIMEOUT_MS).toBe(120_000);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Integration contract for how execute-cursor/index.ts wires the watchdog to
|
|
115
|
+
* the SDK Run: on stall it must (1) record the stall, (2) build a
|
|
116
|
+
* StallTimeoutError, and (3) cancel the run when the SDK supports cancel —
|
|
117
|
+
* exactly the onStall closure in index.ts. We assert that contract here against
|
|
118
|
+
* a faithful fake Run rather than standing up the full executeCursor activity
|
|
119
|
+
* (which has no end-to-end test harness), keeping the test focused and robust.
|
|
120
|
+
*/
|
|
121
|
+
describe("stall -> run.cancel() wiring contract (mirrors execute-cursor/index.ts)", () => {
|
|
122
|
+
beforeEach(() => vi.useFakeTimers());
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
vi.restoreAllMocks();
|
|
125
|
+
vi.useRealTimers();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
interface FakeRun {
|
|
129
|
+
supports: (op: string) => boolean;
|
|
130
|
+
cancel: () => Promise<void>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function wire(run: FakeRun, stallMs: number) {
|
|
134
|
+
const state: { stallDetected: boolean; stallError?: StallTimeoutError } = {
|
|
135
|
+
stallDetected: false,
|
|
136
|
+
};
|
|
137
|
+
let lastToolName: string | undefined;
|
|
138
|
+
const wd = startStallWatchdog(stallMs, (idleMs) => {
|
|
139
|
+
state.stallDetected = true;
|
|
140
|
+
state.stallError = new StallTimeoutError(idleMs, lastToolName ? `last tool: ${lastToolName}` : undefined);
|
|
141
|
+
if (run.supports?.("cancel")) void run.cancel();
|
|
142
|
+
});
|
|
143
|
+
return {
|
|
144
|
+
state,
|
|
145
|
+
recordActivity: (tool?: string) => {
|
|
146
|
+
if (tool) lastToolName = tool;
|
|
147
|
+
wd.recordActivity();
|
|
148
|
+
},
|
|
149
|
+
stop: () => wd.stop(),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
it("cancels the run and records a StallTimeoutError when the stream wedges", () => {
|
|
154
|
+
const cancel = vi.fn().mockResolvedValue(undefined);
|
|
155
|
+
const run: FakeRun = { supports: () => true, cancel };
|
|
156
|
+
const w = wire(run, 60_000);
|
|
157
|
+
|
|
158
|
+
// Simulate a tool call starting, then no further progress.
|
|
159
|
+
w.recordActivity("browser_capture");
|
|
160
|
+
vi.advanceTimersByTime(61_000);
|
|
161
|
+
|
|
162
|
+
expect(w.state.stallDetected).toBe(true);
|
|
163
|
+
expect(cancel).toHaveBeenCalledTimes(1);
|
|
164
|
+
expect(w.state.stallError).toBeInstanceOf(StallTimeoutError);
|
|
165
|
+
expect(formatStallFailure(w.state.stallError!)).toContain("last tool: browser_capture");
|
|
166
|
+
w.stop();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("does not call cancel when the SDK does not support it", () => {
|
|
170
|
+
const cancel = vi.fn().mockResolvedValue(undefined);
|
|
171
|
+
const run: FakeRun = { supports: () => false, cancel };
|
|
172
|
+
const w = wire(run, 30_000);
|
|
173
|
+
|
|
174
|
+
vi.advanceTimersByTime(31_000);
|
|
175
|
+
expect(w.state.stallDetected).toBe(true);
|
|
176
|
+
expect(cancel).not.toHaveBeenCalled();
|
|
177
|
+
w.stop();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("a stream that keeps making progress never stalls or cancels", () => {
|
|
181
|
+
const cancel = vi.fn().mockResolvedValue(undefined);
|
|
182
|
+
const run: FakeRun = { supports: () => true, cancel };
|
|
183
|
+
const w = wire(run, 60_000);
|
|
184
|
+
|
|
185
|
+
for (let i = 0; i < 20; i++) {
|
|
186
|
+
vi.advanceTimersByTime(30_000);
|
|
187
|
+
w.recordActivity("scroll");
|
|
188
|
+
}
|
|
189
|
+
expect(w.state.stallDetected).toBe(false);
|
|
190
|
+
expect(cancel).not.toHaveBeenCalled();
|
|
191
|
+
w.stop();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { create, toBinary } from "@bufbuild/protobuf";
|
|
3
|
+
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
4
|
+
import { AgentMessageSchema, ToolCallSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
5
|
+
import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
6
|
+
import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
7
|
+
import type { ArtifactStorage } from "../artifact-storage.js";
|
|
8
|
+
import {
|
|
9
|
+
offloadOversizedToolOutputs,
|
|
10
|
+
enforceStatusSizeLimit,
|
|
11
|
+
detectImagePayload,
|
|
12
|
+
} from "../status-offload.js";
|
|
13
|
+
|
|
14
|
+
function makeFakeStorage() {
|
|
15
|
+
const uploads: { key: string; size: number; contentType?: string }[] = [];
|
|
16
|
+
const storage: ArtifactStorage = {
|
|
17
|
+
upload: vi.fn(async (key: string, content: Buffer, contentType?: string) => {
|
|
18
|
+
uploads.push({ key, size: content.length, contentType });
|
|
19
|
+
return key;
|
|
20
|
+
}),
|
|
21
|
+
getDownloadUrl: vi.fn(async (key: string) => `https://artifacts.local/${key}`),
|
|
22
|
+
exists: vi.fn(async () => true),
|
|
23
|
+
};
|
|
24
|
+
return { storage, uploads };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function statusWithToolCall(tc: ToolCall): AgentExecutionStatus {
|
|
28
|
+
return create(AgentExecutionStatusSchema, {
|
|
29
|
+
messages: [create(AgentMessageSchema, { toolCalls: [tc] })],
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function encodedSize(status: AgentExecutionStatus): number {
|
|
34
|
+
return toBinary(AgentExecutionStatusSchema, status).length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const BIG_BASE64_IMAGE = Buffer.from("x".repeat(4096)).toString("base64");
|
|
38
|
+
|
|
39
|
+
describe("detectImagePayload", () => {
|
|
40
|
+
it("extracts an MCP image content block", () => {
|
|
41
|
+
const result = JSON.stringify([
|
|
42
|
+
{ type: "text", text: "Screenshot captured" },
|
|
43
|
+
{ type: "image", data: BIG_BASE64_IMAGE, mimeType: "image/png" },
|
|
44
|
+
]);
|
|
45
|
+
const img = detectImagePayload(result);
|
|
46
|
+
expect(img).not.toBeNull();
|
|
47
|
+
expect(img?.mimeType).toBe("image/png");
|
|
48
|
+
expect(img?.base64).toBe(BIG_BASE64_IMAGE);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("extracts a raw data URL", () => {
|
|
52
|
+
const img = detectImagePayload(`data:image/jpeg;base64,${BIG_BASE64_IMAGE}`);
|
|
53
|
+
expect(img?.mimeType).toBe("image/jpeg");
|
|
54
|
+
expect(img?.base64).toBe(BIG_BASE64_IMAGE);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns null for plain text", () => {
|
|
58
|
+
expect(detectImagePayload("just some logs")).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("extracts an image from a serialized LangChain envelope (kwargs.content)", () => {
|
|
62
|
+
// Defensive shape: blocks nested under kwargs.content rather than a
|
|
63
|
+
// top-level array. The extractor normalizes to a top-level array, but
|
|
64
|
+
// detection must still cope if that ever drifts.
|
|
65
|
+
const result = JSON.stringify({
|
|
66
|
+
lc: 1,
|
|
67
|
+
type: "constructor",
|
|
68
|
+
id: ["langchain_core", "messages", "ToolMessage"],
|
|
69
|
+
kwargs: { content: [{ type: "image", data: BIG_BASE64_IMAGE, mimeType: "image/png" }] },
|
|
70
|
+
});
|
|
71
|
+
const img = detectImagePayload(result);
|
|
72
|
+
expect(img?.mimeType).toBe("image/png");
|
|
73
|
+
expect(img?.base64).toBe(BIG_BASE64_IMAGE);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("offloadOversizedToolOutputs", () => {
|
|
78
|
+
it("offloads an oversized image result and renders it inline-able", async () => {
|
|
79
|
+
const { storage, uploads } = makeFakeStorage();
|
|
80
|
+
const result = JSON.stringify([
|
|
81
|
+
{ type: "image", data: BIG_BASE64_IMAGE, mimeType: "image/png" },
|
|
82
|
+
]);
|
|
83
|
+
const tc = create(ToolCallSchema, { id: "tc-1", name: "screenshot", result });
|
|
84
|
+
const status = statusWithToolCall(tc);
|
|
85
|
+
|
|
86
|
+
await offloadOversizedToolOutputs(status, {
|
|
87
|
+
artifactStorage: storage,
|
|
88
|
+
executionId: "exec-1",
|
|
89
|
+
maxInlineBytes: 256,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const out = status.messages[0].toolCalls[0];
|
|
93
|
+
expect(out.outputRef).toBeDefined();
|
|
94
|
+
expect(out.outputRef?.isImage).toBe(true);
|
|
95
|
+
expect(out.outputRef?.mimeType).toBe("image/png");
|
|
96
|
+
expect(out.outputRef?.downloadUrl).toContain("artifacts/exec-1/toolcalls/tc-1.png");
|
|
97
|
+
// result is collapsed to a short label, no longer the giant blob.
|
|
98
|
+
expect(out.result.length).toBeLessThan(200);
|
|
99
|
+
expect(uploads).toHaveLength(1);
|
|
100
|
+
expect(uploads[0].contentType).toBe("image/png");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("offloads oversized text with a preview head and text/plain upload", async () => {
|
|
104
|
+
const { storage, uploads } = makeFakeStorage();
|
|
105
|
+
const result = "LOG ".repeat(2000); // ~8 KB
|
|
106
|
+
const tc = create(ToolCallSchema, { id: "tc-2", name: "Shell", result });
|
|
107
|
+
const status = statusWithToolCall(tc);
|
|
108
|
+
|
|
109
|
+
await offloadOversizedToolOutputs(status, {
|
|
110
|
+
artifactStorage: storage,
|
|
111
|
+
executionId: "exec-1",
|
|
112
|
+
maxInlineBytes: 256,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const out = status.messages[0].toolCalls[0];
|
|
116
|
+
expect(out.outputRef?.isImage).toBe(false);
|
|
117
|
+
expect(out.outputRef?.mimeType).toBe("text/plain");
|
|
118
|
+
expect(out.outputRef?.truncatedPreview.length).toBeGreaterThan(0);
|
|
119
|
+
expect(out.result).toContain("view full output");
|
|
120
|
+
expect(uploads).toHaveLength(1);
|
|
121
|
+
expect(uploads[0].contentType).toBe("text/plain");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("offloads a SMALL image regardless of size (output_ref is the only render path)", async () => {
|
|
125
|
+
const { storage, uploads } = makeFakeStorage();
|
|
126
|
+
// A tiny image, far below the inline byte budget. It must still offload,
|
|
127
|
+
// because the UI can only render an image through ToolCallOutputRef.
|
|
128
|
+
const smallImage = Buffer.from("tiny-png").toString("base64");
|
|
129
|
+
const result = JSON.stringify([
|
|
130
|
+
{ type: "image", data: smallImage, mimeType: "image/png" },
|
|
131
|
+
]);
|
|
132
|
+
const tc = create(ToolCallSchema, { id: "tc-img", name: "screenshot", result });
|
|
133
|
+
const status = statusWithToolCall(tc);
|
|
134
|
+
expect(result.length).toBeLessThan(256);
|
|
135
|
+
|
|
136
|
+
await offloadOversizedToolOutputs(status, {
|
|
137
|
+
artifactStorage: storage,
|
|
138
|
+
executionId: "exec-1",
|
|
139
|
+
maxInlineBytes: 256,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const out = status.messages[0].toolCalls[0];
|
|
143
|
+
expect(out.outputRef?.isImage).toBe(true);
|
|
144
|
+
expect(out.outputRef?.downloadUrl).toContain("artifacts/exec-1/toolcalls/tc-img.png");
|
|
145
|
+
expect(out.result).not.toContain(smallImage);
|
|
146
|
+
expect(uploads).toHaveLength(1);
|
|
147
|
+
expect(uploads[0].contentType).toBe("image/png");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("leaves small results untouched (no artifact, no ref)", async () => {
|
|
151
|
+
const { storage, uploads } = makeFakeStorage();
|
|
152
|
+
const tc = create(ToolCallSchema, { id: "tc-3", name: "Read", result: "small output" });
|
|
153
|
+
const status = statusWithToolCall(tc);
|
|
154
|
+
|
|
155
|
+
await offloadOversizedToolOutputs(status, {
|
|
156
|
+
artifactStorage: storage,
|
|
157
|
+
executionId: "exec-1",
|
|
158
|
+
maxInlineBytes: 256,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const out = status.messages[0].toolCalls[0];
|
|
162
|
+
expect(out.outputRef).toBeUndefined();
|
|
163
|
+
expect(out.result).toBe("small output");
|
|
164
|
+
expect(uploads).toHaveLength(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("is idempotent and dedupes uploads when result is re-inflated with identical bytes", async () => {
|
|
168
|
+
const { storage, uploads } = makeFakeStorage();
|
|
169
|
+
const result = "LOG ".repeat(2000);
|
|
170
|
+
const tc = create(ToolCallSchema, { id: "tc-4", name: "Shell", result });
|
|
171
|
+
const status = statusWithToolCall(tc);
|
|
172
|
+
const ctx = { artifactStorage: storage, executionId: "exec-1", maxInlineBytes: 256 };
|
|
173
|
+
|
|
174
|
+
await offloadOversizedToolOutputs(status, ctx);
|
|
175
|
+
expect(uploads).toHaveLength(1);
|
|
176
|
+
|
|
177
|
+
// Simulate mergeToolCallEvent re-inflating the inline result with the SAME
|
|
178
|
+
// bytes on a subsequent persist; the ref's content hash still matches.
|
|
179
|
+
status.messages[0].toolCalls[0].result = result;
|
|
180
|
+
await offloadOversizedToolOutputs(status, ctx);
|
|
181
|
+
|
|
182
|
+
expect(uploads).toHaveLength(1); // not re-uploaded
|
|
183
|
+
expect(status.messages[0].toolCalls[0].result).toContain("view full output");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("keeps the encoded status small after offloading a large blob", async () => {
|
|
187
|
+
const { storage } = makeFakeStorage();
|
|
188
|
+
const result = JSON.stringify([
|
|
189
|
+
{ type: "image", data: Buffer.from("y".repeat(500_000)).toString("base64"), mimeType: "image/png" },
|
|
190
|
+
]);
|
|
191
|
+
const tc = create(ToolCallSchema, { id: "tc-5", name: "screenshot", result });
|
|
192
|
+
const status = statusWithToolCall(tc);
|
|
193
|
+
expect(encodedSize(status)).toBeGreaterThan(500_000);
|
|
194
|
+
|
|
195
|
+
await offloadOversizedToolOutputs(status, {
|
|
196
|
+
artifactStorage: storage,
|
|
197
|
+
executionId: "exec-1",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(encodedSize(status)).toBeLessThan(10_000);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("falls back to inline truncation when the upload fails (never throws)", async () => {
|
|
204
|
+
const storage: ArtifactStorage = {
|
|
205
|
+
upload: vi.fn(async () => { throw new Error("storage down"); }),
|
|
206
|
+
getDownloadUrl: vi.fn(async (k: string) => k),
|
|
207
|
+
exists: vi.fn(async () => false),
|
|
208
|
+
};
|
|
209
|
+
const result = "LOG ".repeat(2000);
|
|
210
|
+
const tc = create(ToolCallSchema, { id: "tc-6", name: "Shell", result });
|
|
211
|
+
const status = statusWithToolCall(tc);
|
|
212
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
213
|
+
|
|
214
|
+
await expect(
|
|
215
|
+
offloadOversizedToolOutputs(status, {
|
|
216
|
+
artifactStorage: storage,
|
|
217
|
+
executionId: "exec-1",
|
|
218
|
+
maxInlineBytes: 256,
|
|
219
|
+
}),
|
|
220
|
+
).resolves.toBeUndefined();
|
|
221
|
+
|
|
222
|
+
const out = status.messages[0].toolCalls[0];
|
|
223
|
+
expect(out.outputRef).toBeUndefined();
|
|
224
|
+
expect(out.result).toContain("offload failed");
|
|
225
|
+
expect(out.result.length).toBeLessThan(result.length);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("enforceStatusSizeLimit", () => {
|
|
230
|
+
it("returns false and changes nothing when under the limit", () => {
|
|
231
|
+
const tc = create(ToolCallSchema, { id: "tc", name: "Read", result: "tiny" });
|
|
232
|
+
const status = statusWithToolCall(tc);
|
|
233
|
+
expect(enforceStatusSizeLimit(status, 1024 * 1024)).toBe(false);
|
|
234
|
+
expect(status.messages[0].toolCalls[0].result).toBe("tiny");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("elides the largest inline fields until the status fits", () => {
|
|
238
|
+
const big = "Z".repeat(50_000);
|
|
239
|
+
const status = create(AgentExecutionStatusSchema, {
|
|
240
|
+
messages: [
|
|
241
|
+
create(AgentMessageSchema, {
|
|
242
|
+
toolCalls: [
|
|
243
|
+
create(ToolCallSchema, { id: "a", name: "Shell", result: big }),
|
|
244
|
+
create(ToolCallSchema, { id: "b", name: "Read", result: big }),
|
|
245
|
+
],
|
|
246
|
+
}),
|
|
247
|
+
],
|
|
248
|
+
});
|
|
249
|
+
const before = encodedSize(status);
|
|
250
|
+
expect(before).toBeGreaterThan(80_000);
|
|
251
|
+
|
|
252
|
+
const elided = enforceStatusSizeLimit(status, 4_000);
|
|
253
|
+
expect(elided).toBe(true);
|
|
254
|
+
expect(encodedSize(status)).toBeLessThanOrEqual(4_000);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { ConnectError, Code } from "@connectrpc/connect";
|
|
2
3
|
import { create } from "@bufbuild/protobuf";
|
|
3
4
|
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
4
5
|
import { ExecutionControlSignal, ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
@@ -123,6 +124,204 @@ describe("persistStatus", () => {
|
|
|
123
124
|
expect(consoleSpy).toHaveBeenCalled();
|
|
124
125
|
consoleSpy.mockRestore();
|
|
125
126
|
});
|
|
127
|
+
|
|
128
|
+
it("hard-elides and retries once on a resource_exhausted (code 8) failure", async () => {
|
|
129
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
130
|
+
const updateStatus = vi
|
|
131
|
+
.fn()
|
|
132
|
+
.mockRejectedValueOnce({ code: 8, message: "resource_exhausted: exceeds maximum size" })
|
|
133
|
+
.mockResolvedValueOnce({ signal: ExecutionControlSignal.STOP });
|
|
134
|
+
const mockClient = { updateStatus } as any;
|
|
135
|
+
const status = create(AgentExecutionStatusSchema, {
|
|
136
|
+
phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const signal = await persistStatus(mockClient, "exec-too-big", status);
|
|
140
|
+
expect(signal).toBe(ExecutionControlSignal.STOP);
|
|
141
|
+
expect(updateStatus).toHaveBeenCalledTimes(2);
|
|
142
|
+
consoleSpy.mockRestore();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("offloads oversized tool outputs before persisting when given an offload context", async () => {
|
|
146
|
+
const uploaded: string[] = [];
|
|
147
|
+
const offload = {
|
|
148
|
+
executionId: "exec-off",
|
|
149
|
+
artifactStorage: {
|
|
150
|
+
upload: vi.fn(async (key: string) => { uploaded.push(key); return key; }),
|
|
151
|
+
getDownloadUrl: vi.fn(async (key: string) => `https://artifacts.local/${key}`),
|
|
152
|
+
exists: vi.fn(async () => true),
|
|
153
|
+
},
|
|
154
|
+
maxInlineBytes: 256,
|
|
155
|
+
};
|
|
156
|
+
const mockClient = {
|
|
157
|
+
updateStatus: vi.fn().mockResolvedValue({ signal: ExecutionControlSignal.UNSPECIFIED }),
|
|
158
|
+
} as any;
|
|
159
|
+
const { AgentMessageSchema, ToolCallSchema } = await import(
|
|
160
|
+
"@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb"
|
|
161
|
+
);
|
|
162
|
+
const status = create(AgentExecutionStatusSchema, {
|
|
163
|
+
phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
|
|
164
|
+
messages: [
|
|
165
|
+
create(AgentMessageSchema, {
|
|
166
|
+
toolCalls: [create(ToolCallSchema, { id: "t", name: "Shell", result: "X".repeat(5000) })],
|
|
167
|
+
}),
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await persistStatus(mockClient, "exec-off", status, { offload });
|
|
172
|
+
|
|
173
|
+
expect(uploaded).toHaveLength(1);
|
|
174
|
+
const [, persisted] = mockClient.updateStatus.mock.calls[0];
|
|
175
|
+
expect(persisted.messages[0].toolCalls[0].outputRef).toBeDefined();
|
|
176
|
+
expect(persisted.messages[0].toolCalls[0].result.length).toBeLessThan(5000);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Migrated from grpc-retry.test.ts: the transient-backoff loop now lives inside
|
|
181
|
+
// persistStatus so every persist (streaming + terminal, every harness) shares
|
|
182
|
+
// one chokepoint that bounds size AND retries transient transport errors.
|
|
183
|
+
describe("persistStatus — transient retry", () => {
|
|
184
|
+
const noDelay = async () => {};
|
|
185
|
+
const emptyStatus = () => create(AgentExecutionStatusSchema, {});
|
|
186
|
+
function retryClient(impl: () => Promise<{ signal: ExecutionControlSignal }>) {
|
|
187
|
+
return { updateStatus: vi.fn(impl) } as any;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it("returns the signal on the first successful attempt", async () => {
|
|
191
|
+
const client = retryClient(async () => ({ signal: ExecutionControlSignal.STOP }));
|
|
192
|
+
const signal = await persistStatus(client, "exec-1", emptyStatus(), {
|
|
193
|
+
retry: { delayFn: noDelay },
|
|
194
|
+
});
|
|
195
|
+
expect(signal).toBe(ExecutionControlSignal.STOP);
|
|
196
|
+
expect(client.updateStatus).toHaveBeenCalledOnce();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("retries a transient UNAVAILABLE and then succeeds", async () => {
|
|
200
|
+
let attempt = 0;
|
|
201
|
+
const client = retryClient(async () => {
|
|
202
|
+
attempt++;
|
|
203
|
+
if (attempt === 1) throw new ConnectError("down", Code.Unavailable);
|
|
204
|
+
return { signal: ExecutionControlSignal.UNSPECIFIED };
|
|
205
|
+
});
|
|
206
|
+
const signal = await persistStatus(client, "exec-2", emptyStatus(), {
|
|
207
|
+
retry: { delayFn: noDelay },
|
|
208
|
+
});
|
|
209
|
+
expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
|
|
210
|
+
expect(attempt).toBe(2);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("does not retry terminal errors (NOT_FOUND)", async () => {
|
|
214
|
+
let attempt = 0;
|
|
215
|
+
const client = retryClient(async () => {
|
|
216
|
+
attempt++;
|
|
217
|
+
throw new ConnectError("gone", Code.NotFound);
|
|
218
|
+
});
|
|
219
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
220
|
+
const signal = await persistStatus(client, "exec-3", emptyStatus(), {
|
|
221
|
+
retry: { delayFn: noDelay },
|
|
222
|
+
});
|
|
223
|
+
errorSpy.mockRestore();
|
|
224
|
+
expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
|
|
225
|
+
expect(attempt).toBe(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("does not retry INTERNAL (neither retryable nor terminal)", async () => {
|
|
229
|
+
let attempt = 0;
|
|
230
|
+
const client = retryClient(async () => {
|
|
231
|
+
attempt++;
|
|
232
|
+
throw new ConnectError("internal", Code.Internal);
|
|
233
|
+
});
|
|
234
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
235
|
+
await persistStatus(client, "exec-int", emptyStatus(), {
|
|
236
|
+
retry: { maxRetries: 3, delayFn: noDelay },
|
|
237
|
+
});
|
|
238
|
+
errorSpy.mockRestore();
|
|
239
|
+
expect(attempt).toBe(1);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("applies exponential backoff and gives up after the retry budget", async () => {
|
|
243
|
+
const delays: number[] = [];
|
|
244
|
+
const client = retryClient(async () => {
|
|
245
|
+
throw new ConnectError("down", Code.Unavailable);
|
|
246
|
+
});
|
|
247
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
248
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
249
|
+
const signal = await persistStatus(client, "exec-5", emptyStatus(), {
|
|
250
|
+
retry: {
|
|
251
|
+
baseDelayMs: 100,
|
|
252
|
+
backoffFactor: 2,
|
|
253
|
+
maxRetries: 3,
|
|
254
|
+
delayFn: async (ms) => { delays.push(ms); },
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
warnSpy.mockRestore();
|
|
258
|
+
errorSpy.mockRestore();
|
|
259
|
+
expect(delays).toEqual([100, 200, 400]);
|
|
260
|
+
expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("respects a custom backoff factor", async () => {
|
|
264
|
+
const delays: number[] = [];
|
|
265
|
+
const client = retryClient(async () => {
|
|
266
|
+
throw new ConnectError("down", Code.Unavailable);
|
|
267
|
+
});
|
|
268
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
269
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
270
|
+
await persistStatus(client, "exec-bf", emptyStatus(), {
|
|
271
|
+
retry: {
|
|
272
|
+
baseDelayMs: 50,
|
|
273
|
+
backoffFactor: 3,
|
|
274
|
+
maxRetries: 3,
|
|
275
|
+
delayFn: async (ms) => { delays.push(ms); },
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
warnSpy.mockRestore();
|
|
279
|
+
errorSpy.mockRestore();
|
|
280
|
+
expect(delays).toEqual([50, 150, 450]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("maxRetries = 0 means a single attempt", async () => {
|
|
284
|
+
let attempt = 0;
|
|
285
|
+
const client = retryClient(async () => {
|
|
286
|
+
attempt++;
|
|
287
|
+
throw new ConnectError("down", Code.Unavailable);
|
|
288
|
+
});
|
|
289
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
290
|
+
await persistStatus(client, "exec-0", emptyStatus(), {
|
|
291
|
+
retry: { maxRetries: 0, delayFn: noDelay },
|
|
292
|
+
});
|
|
293
|
+
errorSpy.mockRestore();
|
|
294
|
+
expect(attempt).toBe(1);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("handles a mixed sequence: transient then terminal", async () => {
|
|
298
|
+
let attempt = 0;
|
|
299
|
+
const client = retryClient(async () => {
|
|
300
|
+
attempt++;
|
|
301
|
+
if (attempt === 1) throw new ConnectError("down", Code.Unavailable);
|
|
302
|
+
throw new ConnectError("gone", Code.NotFound);
|
|
303
|
+
});
|
|
304
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
305
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
306
|
+
const signal = await persistStatus(client, "exec-mix", emptyStatus(), {
|
|
307
|
+
retry: { delayFn: noDelay },
|
|
308
|
+
});
|
|
309
|
+
warnSpy.mockRestore();
|
|
310
|
+
errorSpy.mockRestore();
|
|
311
|
+
expect(attempt).toBe(2);
|
|
312
|
+
expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("never throws regardless of error type", async () => {
|
|
316
|
+
const client = retryClient(async () => {
|
|
317
|
+
throw new ConnectError("server exploded", Code.Internal);
|
|
318
|
+
});
|
|
319
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
320
|
+
await expect(
|
|
321
|
+
persistStatus(client, "exec-7", emptyStatus(), { retry: { delayFn: noDelay } }),
|
|
322
|
+
).resolves.toBe(ExecutionControlSignal.UNSPECIFIED);
|
|
323
|
+
errorSpy.mockRestore();
|
|
324
|
+
});
|
|
126
325
|
});
|
|
127
326
|
|
|
128
327
|
describe("reportSetupProgress", () => {
|