@stigmer/runner 3.12.1 → 3.12.3
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/capture-flow.d.ts +5 -4
- package/dist/activities/execute-cursor/capture-flow.js +5 -4
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/cas-observations.d.ts +10 -4
- package/dist/activities/execute-cursor/cas-observations.js +10 -4
- package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.js +39 -13
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.d.ts +23 -0
- package/dist/activities/execute-cursor/skill-resolver.js +121 -32
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +9 -1
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +13 -2
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +3 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.js +3 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +8 -0
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.js +25 -10
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/generate-session-subject.d.ts +80 -0
- package/dist/activities/generate-session-subject.js +283 -0
- package/dist/activities/generate-session-subject.js.map +1 -0
- package/dist/client/stigmer-client.d.ts +17 -1
- package/dist/client/stigmer-client.js +20 -0
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/middleware/approval-gate.d.ts +25 -10
- package/dist/middleware/approval-gate.js +33 -14
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/middleware/index.d.ts +6 -5
- package/dist/middleware/index.js +8 -5
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/otel-spans.d.ts +2 -1
- package/dist/middleware/otel-spans.js +2 -1
- package/dist/middleware/otel-spans.js.map +1 -1
- package/dist/middleware/tool-intent.d.ts +57 -0
- package/dist/middleware/tool-intent.js +152 -0
- package/dist/middleware/tool-intent.js.map +1 -0
- package/dist/runner-manager.js +3 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +3 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/attachment-vision.js +9 -0
- package/dist/shared/attachment-vision.js.map +1 -1
- package/dist/shared/mcp-manager.js +8 -0
- package/dist/shared/mcp-manager.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/__tests__/generate-session-subject.test.ts +348 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +60 -9
- package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +267 -8
- package/src/activities/execute-cursor/capture-flow.ts +5 -4
- package/src/activities/execute-cursor/cas-observations.ts +10 -4
- package/src/activities/execute-cursor/hook-script.ts +39 -13
- package/src/activities/execute-cursor/skill-resolver.ts +148 -37
- package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +13 -2
- package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +21 -20
- package/src/activities/execute-deep-agent/cas-capture-backend.ts +3 -1
- package/src/activities/execute-deep-agent/setup.ts +8 -0
- package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -10
- package/src/activities/generate-session-subject.ts +370 -0
- package/src/client/stigmer-client.ts +23 -1
- package/src/middleware/__tests__/approval-gate.test.ts +130 -1
- package/src/middleware/__tests__/tool-intent.test.ts +266 -0
- package/src/middleware/approval-gate.ts +58 -24
- package/src/middleware/index.ts +9 -5
- package/src/middleware/otel-spans.ts +2 -1
- package/src/middleware/tool-intent.ts +174 -0
- package/src/runner-manager.ts +3 -0
- package/src/runner.ts +3 -0
- package/src/shared/__tests__/attachment-vision.test.ts +4 -0
- package/src/shared/__tests__/mcp-manager.test.ts +7 -2
- package/src/shared/attachment-vision.ts +9 -0
- package/src/shared/filereview/__tests__/capture.test.ts +48 -0
- package/src/shared/mcp-manager.ts +8 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GenerateSessionSubject Temporal activity — replaces the sentinel subject of
|
|
3
|
+
* an auto-created session with a concise LLM-generated conversation title.
|
|
4
|
+
*
|
|
5
|
+
* Called fire-and-forget by the Go InvokeAgentExecutionWorkflow (Step 1.5 of
|
|
6
|
+
* BOTH the deep-agent and cursor flows) on the runner task queue, with a 60 s
|
|
7
|
+
* deadline and a single attempt. Failures are non-critical by contract: the
|
|
8
|
+
* session simply keeps its sentinel subject, and the workflow logs a warning.
|
|
9
|
+
*
|
|
10
|
+
* Cross-edition parity: this is the behavioral twin of stigmer-cloud's
|
|
11
|
+
* GenerateSessionSubjectActivityImpl (Java, runs in-process on the server
|
|
12
|
+
* because the Java server owns an LLM call service; the OSS LLM stack lives
|
|
13
|
+
* here in the runner). The system prompt, skip rules, 50-char cap, and the
|
|
14
|
+
* heuristic fallback are kept in lockstep so both editions title sessions
|
|
15
|
+
* identically. The previous owner was the retired Python agent-runner
|
|
16
|
+
* (stigmer/stigmer#665).
|
|
17
|
+
*
|
|
18
|
+
* Model + credential resolution follows ClassifyToolApprovals: the economy
|
|
19
|
+
* model for the configured primary (registry costTier=economy, graceful
|
|
20
|
+
* degrade to the primary), routed through the Stigmer proxy when one is
|
|
21
|
+
* configured, else called directly with the operator's provider key. Unlike
|
|
22
|
+
* classification — a security gate that fails closed — a missing credential
|
|
23
|
+
* here degrades to a heuristic title (the first words of the user message):
|
|
24
|
+
* a title is worth having even when no model is reachable.
|
|
25
|
+
*
|
|
26
|
+
* Activity contract (the Go stub dispatches exactly ONE argument — the cloud
|
|
27
|
+
* Java signature carries a second, invokerIdentityAccountId, that never
|
|
28
|
+
* crosses this wire):
|
|
29
|
+
* Name: "GenerateSessionSubject"
|
|
30
|
+
* Input: (executionId: string)
|
|
31
|
+
* Output: void
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { SystemMessage, HumanMessage } from "@langchain/core/messages";
|
|
35
|
+
import { ConnectError, Code } from "@connectrpc/connect";
|
|
36
|
+
import { activityStarted, activityFinished } from "../idle-watchdog.js";
|
|
37
|
+
import { StigmerClient } from "../client/stigmer-client.js";
|
|
38
|
+
import { getSummarizationModel } from "../shared/model-registry.js";
|
|
39
|
+
import { buildChatModel } from "../shared/model-client.js";
|
|
40
|
+
import { checkDirectCredentials } from "../shared/llm-backend.js";
|
|
41
|
+
import { tryInferProvider } from "../shared/llm-proxy.js";
|
|
42
|
+
import type { Config } from "../config.js";
|
|
43
|
+
import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
44
|
+
import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
|
|
45
|
+
import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
46
|
+
import type { AgentInstance } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/api_pb";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The sentinel written by the server on auto-created sessions — byte-identical
|
|
50
|
+
* to `autoCreatedSessionSubject` in stigmer-server's agentexecution create
|
|
51
|
+
* pipeline and to AUTO_CREATED_SUBJECT in the cloud activity. Only sessions
|
|
52
|
+
* still carrying it (or an empty subject) are ever titled.
|
|
53
|
+
*/
|
|
54
|
+
export const AUTO_CREATED_SUBJECT = "Auto-created session";
|
|
55
|
+
|
|
56
|
+
const MAX_SUBJECT_LENGTH = 50;
|
|
57
|
+
/** Titles are tiny; matches the cloud activity's completion budget. */
|
|
58
|
+
const MAX_COMPLETION_TOKENS = 100;
|
|
59
|
+
const TEMPERATURE = 0.7;
|
|
60
|
+
|
|
61
|
+
/** Kept verbatim in lockstep with the cloud activity's SYSTEM_PROMPT. */
|
|
62
|
+
const SYSTEM_PROMPT = `\
|
|
63
|
+
You are a session title generator. Given a user's message and agent context, \
|
|
64
|
+
produce a concise conversation title.
|
|
65
|
+
|
|
66
|
+
Rules:
|
|
67
|
+
- 3 to 7 words, maximum 50 characters
|
|
68
|
+
- Capture the user's core intent or topic
|
|
69
|
+
- Be specific (e.g. "PostgreSQL Multi-AZ Setup" not "Database Help")
|
|
70
|
+
- No filler words ("help with", "question about", "I need")
|
|
71
|
+
- No quotes, no punctuation at the end
|
|
72
|
+
- Output ONLY the title, nothing else`;
|
|
73
|
+
|
|
74
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
// Core logic (no Temporal coupling)
|
|
76
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/** The client surface this activity needs — StigmerClient satisfies it. */
|
|
79
|
+
export interface SessionSubjectClient {
|
|
80
|
+
getExecution(executionId: string): Promise<AgentExecution>;
|
|
81
|
+
getSession(sessionId: string): Promise<Session>;
|
|
82
|
+
getAgent(agentId: string): Promise<Agent>;
|
|
83
|
+
getAgentInstance(instanceId: string): Promise<AgentInstance>;
|
|
84
|
+
updateSessionSubject(sessionId: string, subject: string): Promise<Session>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface GenerateSessionSubjectOptions {
|
|
88
|
+
/** Null when the deployment has no proxy — the runner calls providers directly. */
|
|
89
|
+
proxyEndpoint: string | null;
|
|
90
|
+
stigmerToken: string | null;
|
|
91
|
+
primaryModel: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function generateSessionSubject(
|
|
95
|
+
executionId: string,
|
|
96
|
+
client: SessionSubjectClient,
|
|
97
|
+
options: GenerateSessionSubjectOptions,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const execution = await getOrSkip(
|
|
100
|
+
() => client.getExecution(executionId),
|
|
101
|
+
`execution not found: ${executionId}`,
|
|
102
|
+
);
|
|
103
|
+
if (execution === undefined) return;
|
|
104
|
+
|
|
105
|
+
const sessionId = execution.spec?.sessionId ?? "";
|
|
106
|
+
const userMessage = execution.spec?.message ?? "";
|
|
107
|
+
if (sessionId === "") {
|
|
108
|
+
log(`no session_id on execution ${executionId}, skipping`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (userMessage === "") {
|
|
112
|
+
log(`no user message on execution ${executionId}, skipping`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const session = await getOrSkip(
|
|
117
|
+
() => client.getSession(sessionId),
|
|
118
|
+
`session not found: ${sessionId}`,
|
|
119
|
+
);
|
|
120
|
+
if (session === undefined) return;
|
|
121
|
+
|
|
122
|
+
const currentSubject = session.spec?.subject ?? "";
|
|
123
|
+
if (currentSubject !== "" && currentSubject !== AUTO_CREATED_SUBJECT) {
|
|
124
|
+
log(`subject is '${currentSubject}' (already set), skipping`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const agentId = await resolveAgentId(execution, session, client);
|
|
129
|
+
if (agentId === "") {
|
|
130
|
+
log(`cannot resolve agent_id for execution ${executionId}, skipping`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const agent = await getOrSkip(
|
|
135
|
+
() => client.getAgent(agentId),
|
|
136
|
+
`agent not found: ${agentId}`,
|
|
137
|
+
);
|
|
138
|
+
if (agent === undefined) return;
|
|
139
|
+
|
|
140
|
+
const subject = await generateTitle({
|
|
141
|
+
userMessage,
|
|
142
|
+
agentName: agent.metadata?.name ?? "",
|
|
143
|
+
agentDescription: agent.spec?.description ?? "",
|
|
144
|
+
executionId,
|
|
145
|
+
options,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (subject === "") {
|
|
149
|
+
log("subject is empty after generation, skipping");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await client.updateSessionSubject(sessionId, subject);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
log(`failed to persist subject for session ${sessionId}: ${message(err)}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
log(`updated session ${sessionId} subject to '${subject}'`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The agent behind the execution: the direct agent_id when the execution
|
|
165
|
+
* carries one, else resolved through the session's agent-instance chain.
|
|
166
|
+
* Empty string when unresolvable. Mirrors the cloud activity's resolveAgentId.
|
|
167
|
+
*/
|
|
168
|
+
export async function resolveAgentId(
|
|
169
|
+
execution: AgentExecution,
|
|
170
|
+
session: Session,
|
|
171
|
+
client: Pick<SessionSubjectClient, "getAgentInstance">,
|
|
172
|
+
): Promise<string> {
|
|
173
|
+
const direct = execution.spec?.agentId ?? "";
|
|
174
|
+
if (direct !== "") return direct;
|
|
175
|
+
|
|
176
|
+
const instanceId = session.spec?.agentInstanceId ?? "";
|
|
177
|
+
if (instanceId === "") return "";
|
|
178
|
+
|
|
179
|
+
const instance = await getOrSkip(
|
|
180
|
+
() => client.getAgentInstance(instanceId),
|
|
181
|
+
`agent instance not found: ${instanceId}`,
|
|
182
|
+
);
|
|
183
|
+
return instance?.spec?.agentId ?? "";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface GenerateTitleParams {
|
|
187
|
+
userMessage: string;
|
|
188
|
+
agentName: string;
|
|
189
|
+
agentDescription: string;
|
|
190
|
+
executionId: string;
|
|
191
|
+
options: GenerateSessionSubjectOptions;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* LLM title with heuristic degrade. Every unavailability class — no direct
|
|
196
|
+
* credential, model build failure, provider error, empty completion — lands on
|
|
197
|
+
* the heuristic, matching the cloud activity (its LlmCallService swallows
|
|
198
|
+
* provider failures into a null completion; heuristicSubject documents the
|
|
199
|
+
* same "expired API key, rate limit, registry miss" cases).
|
|
200
|
+
*/
|
|
201
|
+
async function generateTitle(params: GenerateTitleParams): Promise<string> {
|
|
202
|
+
const { userMessage, agentName, agentDescription, executionId, options } = params;
|
|
203
|
+
|
|
204
|
+
const model = await getSummarizationModel(options.primaryModel);
|
|
205
|
+
|
|
206
|
+
// Direct mode with no credential path degrades up front with one actionable
|
|
207
|
+
// message (the ClassifyToolApprovals idiom — the provider follows the
|
|
208
|
+
// economy-model resolution, not the operator's key, so the log names it).
|
|
209
|
+
if (!options.proxyEndpoint) {
|
|
210
|
+
const provider = tryInferProvider(model);
|
|
211
|
+
const missing = provider === null ? null : checkDirectCredentials(provider);
|
|
212
|
+
if (missing !== null) {
|
|
213
|
+
log(
|
|
214
|
+
`title model '${model}' (${provider}) has no credential path — ` +
|
|
215
|
+
`using heuristic fallback. ${missing}`,
|
|
216
|
+
);
|
|
217
|
+
return heuristicSubject(userMessage);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const { model: llm } = await buildChatModel({
|
|
223
|
+
modelName: model,
|
|
224
|
+
proxyEndpoint: options.proxyEndpoint ?? undefined,
|
|
225
|
+
stigmerToken: options.stigmerToken ?? undefined,
|
|
226
|
+
headerScope: { executionId },
|
|
227
|
+
maxTokens: MAX_COMPLETION_TOKENS,
|
|
228
|
+
temperature: TEMPERATURE,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const result = await llm.invoke([
|
|
232
|
+
new SystemMessage(SYSTEM_PROMPT),
|
|
233
|
+
new HumanMessage(buildUserPrompt(userMessage, agentName, agentDescription)),
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
const generated = contentToString(result.content).trim();
|
|
237
|
+
if (generated === "") {
|
|
238
|
+
log("LLM returned empty content, using heuristic fallback");
|
|
239
|
+
return heuristicSubject(userMessage);
|
|
240
|
+
}
|
|
241
|
+
return cleanSubject(generated);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
log(`LLM title generation failed, using heuristic fallback: ${message(err)}`);
|
|
244
|
+
return heuristicSubject(userMessage);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Kept in lockstep with the cloud activity's buildUserPrompt. */
|
|
249
|
+
export function buildUserPrompt(
|
|
250
|
+
userMessage: string,
|
|
251
|
+
agentName: string,
|
|
252
|
+
agentDescription: string,
|
|
253
|
+
): string {
|
|
254
|
+
let prompt = `User's first message:\n"${userMessage}"\n\n`;
|
|
255
|
+
prompt += `Agent: ${agentName}\n`;
|
|
256
|
+
if (agentDescription !== "") {
|
|
257
|
+
prompt += `Agent purpose: ${agentDescription}\n`;
|
|
258
|
+
}
|
|
259
|
+
prompt += "\nGenerate the title:";
|
|
260
|
+
return prompt;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Best-effort subject from the first few words of the user message. Used when
|
|
265
|
+
* the LLM is unavailable. Kept in lockstep with the cloud activity.
|
|
266
|
+
*/
|
|
267
|
+
export function heuristicSubject(userMessage: string): string {
|
|
268
|
+
const words = userMessage.trim().split(/\s+/);
|
|
269
|
+
let subject = words.slice(0, 7).join(" ");
|
|
270
|
+
if (subject.length > MAX_SUBJECT_LENGTH) {
|
|
271
|
+
subject = subject.slice(0, MAX_SUBJECT_LENGTH - 3) + "...";
|
|
272
|
+
}
|
|
273
|
+
return subject;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Strip one layer of wrapping quotes and enforce the length cap. Kept in
|
|
278
|
+
* lockstep with the cloud activity.
|
|
279
|
+
*/
|
|
280
|
+
export function cleanSubject(raw: string): string {
|
|
281
|
+
let subject = raw.trim();
|
|
282
|
+
if (subject.length > 1 && subject.startsWith('"') && subject.endsWith('"')) {
|
|
283
|
+
subject = subject.slice(1, -1);
|
|
284
|
+
}
|
|
285
|
+
if (subject.length > 1 && subject.startsWith("'") && subject.endsWith("'")) {
|
|
286
|
+
subject = subject.slice(1, -1);
|
|
287
|
+
}
|
|
288
|
+
subject = subject.trim();
|
|
289
|
+
if (subject.length > MAX_SUBJECT_LENGTH) {
|
|
290
|
+
subject = subject.slice(0, MAX_SUBJECT_LENGTH - 3) + "...";
|
|
291
|
+
}
|
|
292
|
+
return subject;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
296
|
+
// Helpers
|
|
297
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Run a lookup, translating NOT_FOUND into a logged skip (undefined) — the
|
|
301
|
+
* cloud activity's Optional.isEmpty branches. Anything else propagates as a
|
|
302
|
+
* genuine activity failure; the workflow's fire-and-forget wrapper logs it
|
|
303
|
+
* as non-critical.
|
|
304
|
+
*/
|
|
305
|
+
async function getOrSkip<T>(
|
|
306
|
+
fetch: () => Promise<T>,
|
|
307
|
+
skipMessage: string,
|
|
308
|
+
): Promise<T | undefined> {
|
|
309
|
+
try {
|
|
310
|
+
return await fetch();
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (err instanceof ConnectError && err.code === Code.NotFound) {
|
|
313
|
+
log(`${skipMessage}, skipping`);
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
throw err;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** LangChain message content is a string or an array of typed parts. */
|
|
321
|
+
function contentToString(content: unknown): string {
|
|
322
|
+
if (typeof content === "string") return content;
|
|
323
|
+
if (Array.isArray(content)) {
|
|
324
|
+
return content
|
|
325
|
+
.map((part) =>
|
|
326
|
+
typeof part === "object" && part !== null && "text" in part
|
|
327
|
+
? String((part as { text: unknown }).text)
|
|
328
|
+
: "",
|
|
329
|
+
)
|
|
330
|
+
.join("");
|
|
331
|
+
}
|
|
332
|
+
return "";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function message(err: unknown): string {
|
|
336
|
+
return err instanceof Error ? err.message : String(err);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function log(msg: string): void {
|
|
340
|
+
console.log(`[GenerateSessionSubject] ${msg}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
344
|
+
// Temporal Activity Factory
|
|
345
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
export function createGenerateSessionSubjectActivities(config: Config) {
|
|
348
|
+
const client = new StigmerClient({
|
|
349
|
+
endpoint: config.stigmerBackendEndpoint,
|
|
350
|
+
token: config.stigmerToken,
|
|
351
|
+
tokenRef: config.stigmerTokenRef,
|
|
352
|
+
runnerTokenRef: config.stigmerRunnerTokenRef,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
GenerateSessionSubject: async (executionId: string): Promise<void> => {
|
|
357
|
+
activityStarted();
|
|
358
|
+
try {
|
|
359
|
+
log(`started: execution=${executionId}`);
|
|
360
|
+
await generateSessionSubject(executionId, client, {
|
|
361
|
+
proxyEndpoint: config.proxyEndpoint,
|
|
362
|
+
stigmerToken: config.stigmerToken,
|
|
363
|
+
primaryModel: config.primaryModel,
|
|
364
|
+
});
|
|
365
|
+
} finally {
|
|
366
|
+
activityFinished();
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
@@ -35,7 +35,7 @@ import type { AgentInstance } from "@stigmer/protos/ai/stigmer/agentic/agentinst
|
|
|
35
35
|
import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
36
36
|
import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
|
|
37
37
|
import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
38
|
-
import type { GetArtifactResponse } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/io_pb";
|
|
38
|
+
import type { GetArtifactResponse, SkillArtifactDownloadUrl } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/io_pb";
|
|
39
39
|
import { create } from "@bufbuild/protobuf";
|
|
40
40
|
import { ConnectInputSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
|
|
41
41
|
import { ExecutionValueSchema } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/spec_pb";
|
|
@@ -524,6 +524,17 @@ export class StigmerClient {
|
|
|
524
524
|
return this.sessionCommand.update(session);
|
|
525
525
|
}
|
|
526
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Race-safe, field-level subject write. The dedicated updateSubject RPC
|
|
529
|
+
* exists precisely so concurrent writers (title generation vs a user
|
|
530
|
+
* rename) cannot lose each other's updates the way full-resource
|
|
531
|
+
* {@link updateSession} round-trips can — always prefer it for subject
|
|
532
|
+
* changes.
|
|
533
|
+
*/
|
|
534
|
+
async updateSessionSubject(sessionId: string, subject: string): Promise<Session> {
|
|
535
|
+
return this.sessionCommand.updateSubject({ id: sessionId, subject });
|
|
536
|
+
}
|
|
537
|
+
|
|
527
538
|
async getAgent(agentId: string): Promise<Agent> {
|
|
528
539
|
return this.agentQuery.get({ value: agentId });
|
|
529
540
|
}
|
|
@@ -572,6 +583,17 @@ export class StigmerClient {
|
|
|
572
583
|
return this.skillQuery.getArtifact({ artifactStorageKey });
|
|
573
584
|
}
|
|
574
585
|
|
|
586
|
+
/**
|
|
587
|
+
* Mint an HTTP download URL for a skill artifact (#675). Preferred over
|
|
588
|
+
* getSkillArtifact for the actual bytes: the unary response is capped by
|
|
589
|
+
* the server's 10MB gRPC message limit, while skills may be 100MB.
|
|
590
|
+
* Throws ConnectError with Code.Unimplemented against servers that
|
|
591
|
+
* predate the transfer lane — callers fall back to getSkillArtifact.
|
|
592
|
+
*/
|
|
593
|
+
async getSkillArtifactDownloadUrl(artifactStorageKey: string): Promise<SkillArtifactDownloadUrl> {
|
|
594
|
+
return this.skillQuery.getArtifactDownloadUrl({ artifactStorageKey });
|
|
595
|
+
}
|
|
596
|
+
|
|
575
597
|
async createArtifact(input: CreateArtifactInput): Promise<Artifact> {
|
|
576
598
|
return this.artifactCommand.create(input);
|
|
577
599
|
}
|
|
@@ -423,7 +423,10 @@ describe("ApprovalGateMiddleware", () => {
|
|
|
423
423
|
expect(mockedInterrupt).toHaveBeenCalledTimes(1);
|
|
424
424
|
});
|
|
425
425
|
|
|
426
|
-
it("KEEPS GATING a gitignored delete", async () => {
|
|
426
|
+
it("KEEPS GATING a gitignored delete when no CAS routing is configured", async () => {
|
|
427
|
+
// captureIgnored unset: there is no substrate to capture the before-bytes
|
|
428
|
+
// into, so the delete stays on the interrupt gate (fail-closed). The CAS
|
|
429
|
+
// delete-flow cases live in the captureIgnored suite below (issue #303).
|
|
427
430
|
const mw = createApprovalGateMiddleware(makeConfig({
|
|
428
431
|
fileCaptureMode: true,
|
|
429
432
|
isCapturablePath: async () => false,
|
|
@@ -624,6 +627,132 @@ describe("ApprovalGateMiddleware", () => {
|
|
|
624
627
|
});
|
|
625
628
|
});
|
|
626
629
|
|
|
630
|
+
describe("capture mode — CAS delete capture (captureDeleteBefore, issue #303)", () => {
|
|
631
|
+
it("flows a non-secret CAS-owned delete: captures before-bytes, runs, never interrupts", async () => {
|
|
632
|
+
const handler = vi.fn(passthrough);
|
|
633
|
+
const captureDeleteBefore = vi.fn(async () => {});
|
|
634
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
635
|
+
try {
|
|
636
|
+
const mw = createApprovalGateMiddleware(makeConfig({
|
|
637
|
+
fileCaptureMode: true,
|
|
638
|
+
isCapturablePath: async () => false, // gitignored / non-git
|
|
639
|
+
captureIgnored: true,
|
|
640
|
+
captureDeleteBefore,
|
|
641
|
+
fingerprintKey: "test-key",
|
|
642
|
+
executionId: "exec-del",
|
|
643
|
+
}));
|
|
644
|
+
|
|
645
|
+
const result = await mw.wrapToolCall!(
|
|
646
|
+
makeRequest({ name: "delete", args: { path: "scratch/tmp.txt" } }),
|
|
647
|
+
handler,
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
expect((result as ToolMessage).content).toBe("tool result");
|
|
651
|
+
expect(captureDeleteBefore).toHaveBeenCalledWith("scratch/tmp.txt");
|
|
652
|
+
expect(handler).toHaveBeenCalledTimes(1); // the delete flowed (apply-then-review)
|
|
653
|
+
expect(mockedInterrupt).not.toHaveBeenCalled();
|
|
654
|
+
|
|
655
|
+
// The gateway records the flow with file_capture provenance, like writes.
|
|
656
|
+
const receipt = logSpy.mock.calls
|
|
657
|
+
.map((c) => String(c[0] ?? ""))
|
|
658
|
+
.filter((line) => line.startsWith("[hitl-gateway] receipt "))
|
|
659
|
+
.map((line) => JSON.parse(line.slice("[hitl-gateway] receipt ".length)) as Record<string, unknown>)[0];
|
|
660
|
+
expect(receipt.policySource).toBe("file_capture");
|
|
661
|
+
expect(receipt.category).toBe("delete");
|
|
662
|
+
} finally {
|
|
663
|
+
logSpy.mockRestore();
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("captures before-bytes BEFORE the tool runs (the bytes exist only until then)", async () => {
|
|
668
|
+
const order: string[] = [];
|
|
669
|
+
const captureDeleteBefore = vi.fn(async () => { order.push("capture"); });
|
|
670
|
+
const handler = vi.fn((req: ToolCallRequest) => {
|
|
671
|
+
order.push("delete");
|
|
672
|
+
return passthrough(req);
|
|
673
|
+
});
|
|
674
|
+
const mw = createApprovalGateMiddleware(makeConfig({
|
|
675
|
+
fileCaptureMode: true,
|
|
676
|
+
isCapturablePath: async () => false,
|
|
677
|
+
captureIgnored: true,
|
|
678
|
+
captureDeleteBefore,
|
|
679
|
+
}));
|
|
680
|
+
|
|
681
|
+
await mw.wrapToolCall!(
|
|
682
|
+
makeRequest({ name: "delete", args: { path: "scratch/tmp.txt" } }),
|
|
683
|
+
handler,
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
expect(order).toEqual(["capture", "delete"]);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("keeps a SECRET-LIKE delete on the interrupt gate: approvable, never captured, never hard-blocked", async () => {
|
|
690
|
+
// A delete's args expose no secret content (unlike a write), so a human
|
|
691
|
+
// may safely approve it — but its before-bytes must never enter CAS.
|
|
692
|
+
const handler = vi.fn(passthrough);
|
|
693
|
+
const captureDeleteBefore = vi.fn(async () => {});
|
|
694
|
+
const recordBlockedSecret = vi.fn();
|
|
695
|
+
const mw = createApprovalGateMiddleware(makeConfig({
|
|
696
|
+
fileCaptureMode: true,
|
|
697
|
+
isCapturablePath: async () => false,
|
|
698
|
+
captureIgnored: true,
|
|
699
|
+
captureDeleteBefore,
|
|
700
|
+
recordBlockedSecret,
|
|
701
|
+
}));
|
|
702
|
+
mockedInterrupt.mockReturnValue({ action: "approve" });
|
|
703
|
+
|
|
704
|
+
const result = await mw.wrapToolCall!(
|
|
705
|
+
makeRequest({ name: "delete", args: { path: ".env" } }),
|
|
706
|
+
handler,
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
expect(mockedInterrupt).toHaveBeenCalledTimes(1); // gated, not blocked
|
|
710
|
+
expect(captureDeleteBefore).not.toHaveBeenCalled(); // bytes never staged
|
|
711
|
+
expect(recordBlockedSecret).not.toHaveBeenCalled(); // not a blocked write
|
|
712
|
+
expect(handler).toHaveBeenCalledTimes(1); // the human approved it
|
|
713
|
+
expect((result as ToolMessage).content).toBe("tool result");
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it("keeps gating a CAS-owned delete when no captureDeleteBefore is configured", async () => {
|
|
717
|
+
// Fail-closed: a delete whose before-bytes cannot be captured cannot be
|
|
718
|
+
// reviewed post-hoc, so it must be approved up front.
|
|
719
|
+
const mw = createApprovalGateMiddleware(makeConfig({
|
|
720
|
+
fileCaptureMode: true,
|
|
721
|
+
isCapturablePath: async () => false,
|
|
722
|
+
captureIgnored: true,
|
|
723
|
+
}));
|
|
724
|
+
mockedInterrupt.mockReturnValue({ action: "approve" });
|
|
725
|
+
|
|
726
|
+
await mw.wrapToolCall!(
|
|
727
|
+
makeRequest({ name: "delete", args: { path: "scratch/tmp.txt" } }),
|
|
728
|
+
passthrough,
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
expect(mockedInterrupt).toHaveBeenCalledTimes(1);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("keeps gating a delete with captureIgnored OFF even when captureDeleteBefore is present", async () => {
|
|
735
|
+
// An unobserved-backend gate (captureIgnored false) must not flow deletes:
|
|
736
|
+
// the callback alone is not a substrate.
|
|
737
|
+
const captureDeleteBefore = vi.fn(async () => {});
|
|
738
|
+
const mw = createApprovalGateMiddleware(makeConfig({
|
|
739
|
+
fileCaptureMode: true,
|
|
740
|
+
isCapturablePath: async () => false,
|
|
741
|
+
captureIgnored: false,
|
|
742
|
+
captureDeleteBefore,
|
|
743
|
+
}));
|
|
744
|
+
mockedInterrupt.mockReturnValue({ action: "approve" });
|
|
745
|
+
|
|
746
|
+
await mw.wrapToolCall!(
|
|
747
|
+
makeRequest({ name: "delete", args: { path: "scratch/tmp.txt" } }),
|
|
748
|
+
passthrough,
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
expect(mockedInterrupt).toHaveBeenCalledTimes(1);
|
|
752
|
+
expect(captureDeleteBefore).not.toHaveBeenCalled();
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
|
|
627
756
|
describe("degraded deny-gate (no capture substrate — non-git + no storage)", () => {
|
|
628
757
|
// When deriveCaptureMode returns false (a non-git workspace with no artifact
|
|
629
758
|
// storage), setup builds the gate with fileCaptureMode/captureIgnored OFF, so
|