clementine-agent 1.18.210 → 1.18.211
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/agent/clarification-gate.d.ts +49 -0
- package/dist/agent/clarification-gate.js +131 -0
- package/dist/agent/precondition-guard.d.ts +61 -0
- package/dist/agent/precondition-guard.js +88 -0
- package/dist/agent/run-agent.d.ts +8 -0
- package/dist/agent/run-agent.js +28 -0
- package/dist/gateway/router.js +29 -0
- package/package.json +1 -1
|
@@ -58,10 +58,59 @@ export interface BlockedActionClassifier {
|
|
|
58
58
|
* state machine; providers own only small classifiers and resume instructions.
|
|
59
59
|
*/
|
|
60
60
|
export declare function registerBlockedActionClassifier(classifier: BlockedActionClassifier): () => void;
|
|
61
|
+
export interface PreconditionEnv {
|
|
62
|
+
/** Working directory the agent will use for this tool call. */
|
|
63
|
+
cwd: string;
|
|
64
|
+
/** Active project path if the router resolved one. */
|
|
65
|
+
activeProjectPath?: string;
|
|
66
|
+
/** filesystem.existsSync injection point for testability. */
|
|
67
|
+
existsSync?: (path: string) => boolean;
|
|
68
|
+
}
|
|
69
|
+
export interface PreconditionMatch {
|
|
70
|
+
/** The command or tool action that would have run (for owner display). */
|
|
71
|
+
attemptedCommand: string;
|
|
72
|
+
/** The reason the precondition failed (for owner display). */
|
|
73
|
+
reason: string;
|
|
74
|
+
/** Optional project path inferred from the input (e.g. `cd /path && netlify deploy`). */
|
|
75
|
+
projectPath?: string;
|
|
76
|
+
}
|
|
77
|
+
export interface PreconditionClassifier {
|
|
78
|
+
id: string;
|
|
79
|
+
category: BlockedActionCategory;
|
|
80
|
+
provider: string;
|
|
81
|
+
providerLabel: string;
|
|
82
|
+
targetNoun: string;
|
|
83
|
+
targetPlaceholder: string;
|
|
84
|
+
blockerSummary: string;
|
|
85
|
+
/** Tool names this classifier inspects. e.g. ['Bash'] or
|
|
86
|
+
* ['mcp__clementine-tools__project_deploy']. Wildcard `'*'` not supported —
|
|
87
|
+
* classifiers should be narrow on purpose. */
|
|
88
|
+
toolNames: readonly string[];
|
|
89
|
+
/** Returns a PreconditionMatch when the call should be blocked, or null
|
|
90
|
+
* to let it proceed. Errors thrown here are caught and treated as a pass
|
|
91
|
+
* (fail-open) — the post-hoc classifier still catches the failure. */
|
|
92
|
+
matchesPreconditions(input: Record<string, unknown>, env: PreconditionEnv): PreconditionMatch | null;
|
|
93
|
+
createInstructions: string[];
|
|
94
|
+
existingInstructions: string[];
|
|
95
|
+
}
|
|
96
|
+
/** Extension point for pre-call classifiers. Mirrors registerBlockedActionClassifier. */
|
|
97
|
+
export declare function registerPreconditionClassifier(classifier: PreconditionClassifier): () => void;
|
|
61
98
|
export declare function buildBlockedActionDecisionFromRunSummary(summary: RunSummary, originalRequest: string, nowMs?: number): PendingAgentDecision | null;
|
|
62
99
|
export declare function parseAgentDecisionReply(decision: PendingAgentDecision, message: string): AgentDecisionReply;
|
|
63
100
|
export declare function buildAgentDecisionContinuationPrompt(decision: PendingAgentDecision, reply: Extract<AgentDecisionReply, {
|
|
64
101
|
kind: 'answer';
|
|
65
102
|
}>): string;
|
|
66
103
|
export declare const buildRepairDecisionFromRunSummary: typeof buildBlockedActionDecisionFromRunSummary;
|
|
104
|
+
/**
|
|
105
|
+
* Run all registered precondition classifiers against an attempted tool
|
|
106
|
+
* call. Returns the first PendingAgentDecision that matches, or null if
|
|
107
|
+
* the call should proceed. Errors inside individual classifiers are
|
|
108
|
+
* swallowed (fail-open) — the post-hoc classifier remains as the safety
|
|
109
|
+
* net for any case the pre-call rule mishandles.
|
|
110
|
+
*/
|
|
111
|
+
export declare function evaluatePreconditionsForToolCall(toolName: string, input: Record<string, unknown>, env: PreconditionEnv, opts?: {
|
|
112
|
+
originalRequest?: string;
|
|
113
|
+
runId?: string;
|
|
114
|
+
nowMs?: number;
|
|
115
|
+
}): PendingAgentDecision | null;
|
|
67
116
|
//# sourceMappingURL=clarification-gate.d.ts.map
|
|
@@ -11,6 +11,16 @@ export function registerBlockedActionClassifier(classifier) {
|
|
|
11
11
|
customClassifiers.splice(index, 1);
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
|
+
const customPreconditionClassifiers = [];
|
|
15
|
+
/** Extension point for pre-call classifiers. Mirrors registerBlockedActionClassifier. */
|
|
16
|
+
export function registerPreconditionClassifier(classifier) {
|
|
17
|
+
customPreconditionClassifiers.unshift(classifier);
|
|
18
|
+
return () => {
|
|
19
|
+
const index = customPreconditionClassifiers.indexOf(classifier);
|
|
20
|
+
if (index >= 0)
|
|
21
|
+
customPreconditionClassifiers.splice(index, 1);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
14
24
|
function firstString(...values) {
|
|
15
25
|
for (const value of values) {
|
|
16
26
|
if (typeof value === 'string' && value.trim())
|
|
@@ -268,4 +278,125 @@ export function buildAgentDecisionContinuationPrompt(decision, reply) {
|
|
|
268
278
|
// Backward-compatible alias for the router/tests while callers migrate to the
|
|
269
279
|
// provider-neutral name.
|
|
270
280
|
export const buildRepairDecisionFromRunSummary = buildBlockedActionDecisionFromRunSummary;
|
|
281
|
+
// ─── Pre-call decision construction ────────────────────────────────────
|
|
282
|
+
function preconditionClassifiers() {
|
|
283
|
+
return [...customPreconditionClassifiers, ...BUILTIN_PRECONDITION_CLASSIFIERS];
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Run all registered precondition classifiers against an attempted tool
|
|
287
|
+
* call. Returns the first PendingAgentDecision that matches, or null if
|
|
288
|
+
* the call should proceed. Errors inside individual classifiers are
|
|
289
|
+
* swallowed (fail-open) — the post-hoc classifier remains as the safety
|
|
290
|
+
* net for any case the pre-call rule mishandles.
|
|
291
|
+
*/
|
|
292
|
+
export function evaluatePreconditionsForToolCall(toolName, input, env, opts = {}) {
|
|
293
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
294
|
+
for (const classifier of preconditionClassifiers()) {
|
|
295
|
+
if (!classifier.toolNames.includes(toolName))
|
|
296
|
+
continue;
|
|
297
|
+
let match = null;
|
|
298
|
+
try {
|
|
299
|
+
match = classifier.matchesPreconditions(input, env);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// Fail-open — the post-hoc classifier will catch a failure.
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (!match)
|
|
306
|
+
continue;
|
|
307
|
+
const decision = {
|
|
308
|
+
id: makeDecisionId('blocked_external_action'),
|
|
309
|
+
kind: 'blocked_external_action',
|
|
310
|
+
createdAt: nowMs,
|
|
311
|
+
expiresAt: nowMs + 30 * 60_000,
|
|
312
|
+
runIds: opts.runId ? [opts.runId] : [],
|
|
313
|
+
originalRequest: opts.originalRequest ?? '',
|
|
314
|
+
question: '',
|
|
315
|
+
context: {
|
|
316
|
+
category: classifier.category,
|
|
317
|
+
classifierId: classifier.id,
|
|
318
|
+
provider: classifier.provider,
|
|
319
|
+
providerLabel: classifier.providerLabel,
|
|
320
|
+
blockerSummary: classifier.blockerSummary,
|
|
321
|
+
failedCommand: compactCommand(match.attemptedCommand),
|
|
322
|
+
error: compactValue(match.reason, 500),
|
|
323
|
+
targetNoun: classifier.targetNoun,
|
|
324
|
+
targetPlaceholder: classifier.targetPlaceholder,
|
|
325
|
+
createInstructions: classifier.createInstructions,
|
|
326
|
+
existingInstructions: classifier.existingInstructions,
|
|
327
|
+
...(match.projectPath ? { projectPath: match.projectPath } : {}),
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
decision.question = formatDecisionPrompt(decision);
|
|
331
|
+
return decision;
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
// ─── Built-in precondition classifiers ─────────────────────────────────
|
|
336
|
+
import { existsSync as nodeExistsSync } from 'node:fs';
|
|
337
|
+
import { join as pathJoin } from 'node:path';
|
|
338
|
+
/**
|
|
339
|
+
* Netlify deploy precondition: deny `netlify deploy` (or our project_deploy
|
|
340
|
+
* tool when its provider is netlify) when the project has no record of a
|
|
341
|
+
* linked site. Mirrors the post-hoc netlify_missing_deployment_target
|
|
342
|
+
* classifier but fires BEFORE the tool runs.
|
|
343
|
+
*
|
|
344
|
+
* Detection signals (any one is sufficient to allow):
|
|
345
|
+
* - `.netlify/state.json` exists in the project dir (netlify CLI's own
|
|
346
|
+
* link record)
|
|
347
|
+
* - `.clementine/deploy.json` exists in the project dir (Clementine's
|
|
348
|
+
* deploy config)
|
|
349
|
+
*
|
|
350
|
+
* If neither exists AND the project dir is identifiable, we deny pre-call
|
|
351
|
+
* and ask the owner. If the project dir is not identifiable (e.g. the
|
|
352
|
+
* agent invoked `netlify deploy` without a `cd` prefix), we fail-open
|
|
353
|
+
* and let the post-hoc classifier handle it.
|
|
354
|
+
*/
|
|
355
|
+
const netlifyMissingLinkPrecondition = {
|
|
356
|
+
id: 'netlify_missing_deployment_target',
|
|
357
|
+
category: 'deployment_target_missing',
|
|
358
|
+
provider: 'netlify',
|
|
359
|
+
providerLabel: 'Netlify',
|
|
360
|
+
targetNoun: 'deployment target',
|
|
361
|
+
targetPlaceholder: 'target-slug-or-id',
|
|
362
|
+
blockerSummary: 'This project does not have a Netlify deployment target linked yet. Deploying will fail until one is configured.',
|
|
363
|
+
toolNames: ['Bash'],
|
|
364
|
+
matchesPreconditions(input, env) {
|
|
365
|
+
const command = firstString(input.command);
|
|
366
|
+
if (!command)
|
|
367
|
+
return null;
|
|
368
|
+
if (!/\bnetlify\s+deploy\b/i.test(command))
|
|
369
|
+
return null;
|
|
370
|
+
const projectPath = extractProjectPathFromCommand(command) ?? env.activeProjectPath;
|
|
371
|
+
if (!projectPath)
|
|
372
|
+
return null; // can't check — fail open
|
|
373
|
+
const exists = env.existsSync ?? nodeExistsSync;
|
|
374
|
+
const netlifyLinked = exists(pathJoin(projectPath, '.netlify', 'state.json'));
|
|
375
|
+
const clementineDeployConfig = exists(pathJoin(projectPath, '.clementine', 'deploy.json'));
|
|
376
|
+
if (netlifyLinked || clementineDeployConfig)
|
|
377
|
+
return null;
|
|
378
|
+
return {
|
|
379
|
+
attemptedCommand: compactCommand(command),
|
|
380
|
+
reason: 'No `.netlify/state.json` or `.clementine/deploy.json` found in the project. Netlify CLI will fail with "Project not found. Please rerun \'netlify link\'".',
|
|
381
|
+
projectPath,
|
|
382
|
+
};
|
|
383
|
+
},
|
|
384
|
+
createInstructions: [
|
|
385
|
+
'Create or link a new Netlify deployment target for this project, then deploy and verify the live URL.',
|
|
386
|
+
'Do not restart project discovery or reread full generated artifacts unless a small targeted read is necessary.',
|
|
387
|
+
'If provider auth, browser login, or an interactive naming choice is required and cannot be completed safely, stop and ask one concrete question.',
|
|
388
|
+
'If a Clementine deploy config is appropriate, write `.clementine/deploy.json` with the provider kind, target identifier, deploy directory, and verify URL.',
|
|
389
|
+
'Prefer `project_deploy` once deploy config exists; otherwise run the equivalent provider deploy command and verify the live URL before claiming success.',
|
|
390
|
+
],
|
|
391
|
+
existingInstructions: [
|
|
392
|
+
'Use or link the existing Netlify target: {target}',
|
|
393
|
+
'Do not restart project discovery or reread full generated artifacts unless a small targeted read is necessary.',
|
|
394
|
+
'Write or update `.clementine/deploy.json` for the existing target before deploying when that config is supported.',
|
|
395
|
+
'Prefer `project_deploy` once deploy config exists; otherwise run the equivalent provider deploy command and verify the live URL before claiming success.',
|
|
396
|
+
'If the provider rejects the target or auth is missing, stop and ask one concrete question with the exact CLI/API error.',
|
|
397
|
+
],
|
|
398
|
+
};
|
|
399
|
+
const BUILTIN_PRECONDITION_CLASSIFIERS = [
|
|
400
|
+
netlifyMissingLinkPrecondition,
|
|
401
|
+
];
|
|
271
402
|
//# sourceMappingURL=clarification-gate.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* precondition-guard — SDK PreToolUse hooks that evaluate the registered
|
|
3
|
+
* precondition classifiers before each tool call. When a classifier
|
|
4
|
+
* matches, the hook returns `permissionDecision: 'deny'` with a reason,
|
|
5
|
+
* captures the PendingAgentDecision in module-scoped state for the run,
|
|
6
|
+
* and runAgent surfaces it to the router via RunAgentResult.
|
|
7
|
+
*
|
|
8
|
+
* Why this exists
|
|
9
|
+
* ───────────────
|
|
10
|
+
* `clarification-gate.ts` already handles owner-decision routing for
|
|
11
|
+
* blocked external actions, but its existing BlockedActionClassifier API
|
|
12
|
+
* is post-hoc: the tool already ran and failed, so we parse the error,
|
|
13
|
+
* inferred the blocker, then asked the owner. That leaves partial state
|
|
14
|
+
* behind (a half-deploy, a created-but-unconfigured target, an emitted
|
|
15
|
+
* webhook that can't be undone).
|
|
16
|
+
*
|
|
17
|
+
* The orchestrator-first north star prefers pre-emptive gates. The SDK
|
|
18
|
+
* exposes PreToolUse hooks that run BEFORE every tool call regardless
|
|
19
|
+
* of permissionMode. We use them to short-circuit known-bad calls
|
|
20
|
+
* before they fire. The same PendingAgentDecision shape flows through
|
|
21
|
+
* the router, so the owner experience is identical — only the failure
|
|
22
|
+
* surface is narrower.
|
|
23
|
+
*
|
|
24
|
+
* Why PreToolUse over canUseTool
|
|
25
|
+
* ──────────────────────────────
|
|
26
|
+
* `canUseTool` is permission-prompt scoped and may be skipped under
|
|
27
|
+
* `bypassPermissions` mode. `PreToolUse` hooks fire universally. The
|
|
28
|
+
* SDK's own doc string on PermissionDeniedMessage confirms:
|
|
29
|
+
*
|
|
30
|
+
* "PreToolUse hook denies bypass canUseTool and are not covered here."
|
|
31
|
+
*
|
|
32
|
+
* That means PreToolUse decisions run FIRST, even when canUseTool is
|
|
33
|
+
* absent or short-circuited. PreToolUse is also already wired into the
|
|
34
|
+
* runAgent hooks pipeline (`tool-output-guard`, `dedup`,
|
|
35
|
+
* `idempotency`), so this fits the established pattern.
|
|
36
|
+
*/
|
|
37
|
+
import type { HookCallbackMatcher, HookEvent } from '@anthropic-ai/claude-agent-sdk';
|
|
38
|
+
import { type PendingAgentDecision } from './clarification-gate.js';
|
|
39
|
+
export interface PreconditionGuardOptions {
|
|
40
|
+
/** Working directory the agent is running in. Used by classifiers to
|
|
41
|
+
* resolve relative project paths. */
|
|
42
|
+
cwd: string;
|
|
43
|
+
/** Active project path if the router resolved one. Optional. */
|
|
44
|
+
activeProjectPath?: string;
|
|
45
|
+
/** The original owner request that started this run. Used to populate
|
|
46
|
+
* PendingAgentDecision.originalRequest when a precondition fires.
|
|
47
|
+
* Optional — the router can fill it in later if needed. */
|
|
48
|
+
originalRequest?: string;
|
|
49
|
+
/** Stable run UUID. Stored on the decision so post-resume continues
|
|
50
|
+
* to reference the same run. */
|
|
51
|
+
runId: string;
|
|
52
|
+
}
|
|
53
|
+
export interface PreconditionGuardHandles {
|
|
54
|
+
/** SDK hook map. Merge into the runAgent hooks pipeline. */
|
|
55
|
+
hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>>;
|
|
56
|
+
/** Read out the captured decision after the SDK stream ends. Null when
|
|
57
|
+
* no precondition fired during the run. */
|
|
58
|
+
getCapturedDecision(): PendingAgentDecision | null;
|
|
59
|
+
}
|
|
60
|
+
export declare function buildPreconditionGuardHooks(opts: PreconditionGuardOptions): PreconditionGuardHandles;
|
|
61
|
+
//# sourceMappingURL=precondition-guard.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* precondition-guard — SDK PreToolUse hooks that evaluate the registered
|
|
3
|
+
* precondition classifiers before each tool call. When a classifier
|
|
4
|
+
* matches, the hook returns `permissionDecision: 'deny'` with a reason,
|
|
5
|
+
* captures the PendingAgentDecision in module-scoped state for the run,
|
|
6
|
+
* and runAgent surfaces it to the router via RunAgentResult.
|
|
7
|
+
*
|
|
8
|
+
* Why this exists
|
|
9
|
+
* ───────────────
|
|
10
|
+
* `clarification-gate.ts` already handles owner-decision routing for
|
|
11
|
+
* blocked external actions, but its existing BlockedActionClassifier API
|
|
12
|
+
* is post-hoc: the tool already ran and failed, so we parse the error,
|
|
13
|
+
* inferred the blocker, then asked the owner. That leaves partial state
|
|
14
|
+
* behind (a half-deploy, a created-but-unconfigured target, an emitted
|
|
15
|
+
* webhook that can't be undone).
|
|
16
|
+
*
|
|
17
|
+
* The orchestrator-first north star prefers pre-emptive gates. The SDK
|
|
18
|
+
* exposes PreToolUse hooks that run BEFORE every tool call regardless
|
|
19
|
+
* of permissionMode. We use them to short-circuit known-bad calls
|
|
20
|
+
* before they fire. The same PendingAgentDecision shape flows through
|
|
21
|
+
* the router, so the owner experience is identical — only the failure
|
|
22
|
+
* surface is narrower.
|
|
23
|
+
*
|
|
24
|
+
* Why PreToolUse over canUseTool
|
|
25
|
+
* ──────────────────────────────
|
|
26
|
+
* `canUseTool` is permission-prompt scoped and may be skipped under
|
|
27
|
+
* `bypassPermissions` mode. `PreToolUse` hooks fire universally. The
|
|
28
|
+
* SDK's own doc string on PermissionDeniedMessage confirms:
|
|
29
|
+
*
|
|
30
|
+
* "PreToolUse hook denies bypass canUseTool and are not covered here."
|
|
31
|
+
*
|
|
32
|
+
* That means PreToolUse decisions run FIRST, even when canUseTool is
|
|
33
|
+
* absent or short-circuited. PreToolUse is also already wired into the
|
|
34
|
+
* runAgent hooks pipeline (`tool-output-guard`, `dedup`,
|
|
35
|
+
* `idempotency`), so this fits the established pattern.
|
|
36
|
+
*/
|
|
37
|
+
import pino from 'pino';
|
|
38
|
+
import { evaluatePreconditionsForToolCall, } from './clarification-gate.js';
|
|
39
|
+
const logger = pino({ name: 'clementine.precondition-guard' });
|
|
40
|
+
export function buildPreconditionGuardHooks(opts) {
|
|
41
|
+
let capturedDecision = null;
|
|
42
|
+
const env = {
|
|
43
|
+
cwd: opts.cwd,
|
|
44
|
+
...(opts.activeProjectPath ? { activeProjectPath: opts.activeProjectPath } : {}),
|
|
45
|
+
};
|
|
46
|
+
const preToolUse = async (input, _toolUseID) => {
|
|
47
|
+
if (input.hook_event_name !== 'PreToolUse') {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
const evt = input;
|
|
51
|
+
const toolName = String(evt.tool_name ?? 'unknown');
|
|
52
|
+
const toolInput = (evt.tool_input ?? {});
|
|
53
|
+
// Already captured a decision earlier in this run — let everything
|
|
54
|
+
// after pass through. The first deny interrupts the loop; subsequent
|
|
55
|
+
// tool calls (if any) shouldn't be re-evaluated.
|
|
56
|
+
if (capturedDecision)
|
|
57
|
+
return {};
|
|
58
|
+
const decision = evaluatePreconditionsForToolCall(toolName, toolInput, env, {
|
|
59
|
+
...(opts.originalRequest ? { originalRequest: opts.originalRequest } : {}),
|
|
60
|
+
runId: opts.runId,
|
|
61
|
+
});
|
|
62
|
+
if (!decision)
|
|
63
|
+
return {};
|
|
64
|
+
capturedDecision = decision;
|
|
65
|
+
logger.info({
|
|
66
|
+
toolName,
|
|
67
|
+
classifierId: decision.context.classifierId,
|
|
68
|
+
provider: decision.context.provider,
|
|
69
|
+
runId: opts.runId,
|
|
70
|
+
}, 'precondition-guard: denied tool call pre-flight; surfacing PendingAgentDecision');
|
|
71
|
+
return {
|
|
72
|
+
hookSpecificOutput: {
|
|
73
|
+
hookEventName: 'PreToolUse',
|
|
74
|
+
permissionDecision: 'deny',
|
|
75
|
+
permissionDecisionReason: decision.question,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
hooks: {
|
|
81
|
+
PreToolUse: [{ hooks: [preToolUse] }],
|
|
82
|
+
},
|
|
83
|
+
getCapturedDecision() {
|
|
84
|
+
return capturedDecision;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=precondition-guard.js.map
|
|
@@ -37,6 +37,7 @@ export declare function invalidateMcpStatusEntry(name: string): {
|
|
|
37
37
|
updatedAt: string;
|
|
38
38
|
};
|
|
39
39
|
import { type ToolOutputGuardConfig } from './tool-output-guard.js';
|
|
40
|
+
import type { PendingAgentDecision } from './clarification-gate.js';
|
|
40
41
|
import type { AgentProfile } from '../types.js';
|
|
41
42
|
import type { AgentManager } from './agent-manager.js';
|
|
42
43
|
import type { MemoryStore } from '../memory/store.js';
|
|
@@ -158,6 +159,13 @@ export interface RunAgentResult {
|
|
|
158
159
|
allowedToolsApplied?: string[];
|
|
159
160
|
builtinToolsApplied?: string[];
|
|
160
161
|
mcpServersApplied?: string[];
|
|
162
|
+
/** A precondition classifier fired during this run, blocking a tool
|
|
163
|
+
* call before it executed. The router surfaces this to the owner via
|
|
164
|
+
* the pendingAgentDecision flow (clarification-gate.ts). Distinct
|
|
165
|
+
* from the post-hoc path that runs on RunSummary.failedSideEffects —
|
|
166
|
+
* this path means the tool NEVER ran, no partial state was left
|
|
167
|
+
* behind. */
|
|
168
|
+
pendingAgentDecision?: PendingAgentDecision;
|
|
161
169
|
}
|
|
162
170
|
/**
|
|
163
171
|
* Run a single agent invocation via the canonical SDK pattern.
|
package/dist/agent/run-agent.js
CHANGED
|
@@ -100,6 +100,7 @@ import { buildDedupHook } from './tool-call-dedup.js';
|
|
|
100
100
|
import { buildSideEffectIdempotencyHook } from './side-effect-idempotency.js';
|
|
101
101
|
import { buildChatStopHook } from './chat-stop-hook.js';
|
|
102
102
|
import { buildRunStateHooks } from './run-state.js';
|
|
103
|
+
import { buildPreconditionGuardHooks } from './precondition-guard.js';
|
|
103
104
|
import { buildAgentMap } from './agent-definitions.js';
|
|
104
105
|
import { buildExecutionToolPolicy, } from './execution-policy.js';
|
|
105
106
|
const MCP_SERVER_SCRIPT = path.join(PKG_DIR, 'dist', 'tools', 'mcp-server.js');
|
|
@@ -523,9 +524,34 @@ export async function runAgent(prompt, opts) {
|
|
|
523
524
|
},
|
|
524
525
|
})
|
|
525
526
|
: null;
|
|
527
|
+
// ── Pre-emptive blocked-action gate (Commit 3 / 1.18.211) ──────────
|
|
528
|
+
// Evaluates registered precondition classifiers before each tool call
|
|
529
|
+
// via a PreToolUse hook. When a classifier matches (e.g. `netlify
|
|
530
|
+
// deploy` with no `.netlify/state.json` and no `.clementine/deploy.json`),
|
|
531
|
+
// the tool is denied with the decision question as the reason and the
|
|
532
|
+
// PendingAgentDecision is captured for surfacing via RunAgentResult.
|
|
533
|
+
// The router stashes it on the session and asks the owner instead of
|
|
534
|
+
// letting the tool fire and leave partial state behind. The existing
|
|
535
|
+
// post-hoc BlockedActionClassifier path (clarification-gate.ts) is
|
|
536
|
+
// unchanged and remains as the safety net for failures the pre-call
|
|
537
|
+
// rule didn't anticipate.
|
|
538
|
+
const preconditionGuard = buildPreconditionGuardHooks({
|
|
539
|
+
runId,
|
|
540
|
+
cwd: opts.cwd ?? BASE_DIR,
|
|
541
|
+
// The chat path already wires `activeProject?.path` into opts.cwd
|
|
542
|
+
// (router.ts), so cwd doubles as the active-project hint for the
|
|
543
|
+
// classifier. Falling back inside the classifier keeps the public
|
|
544
|
+
// RunAgentOptions surface unchanged.
|
|
545
|
+
activeProjectPath: opts.cwd ?? BASE_DIR,
|
|
546
|
+
originalRequest: prompt,
|
|
547
|
+
});
|
|
526
548
|
// Merge hook maps from the modules. SDK accepts arrays of
|
|
527
549
|
// HookCallbackMatcher per event; we concatenate.
|
|
528
550
|
const mergedHooks = { ...guard.hooks };
|
|
551
|
+
for (const [evt, matchers] of Object.entries(preconditionGuard.hooks)) {
|
|
552
|
+
const existing = mergedHooks[evt] ?? [];
|
|
553
|
+
mergedHooks[evt] = [...existing, ...matchers];
|
|
554
|
+
}
|
|
529
555
|
for (const [evt, matchers] of Object.entries(idempotency.hooks)) {
|
|
530
556
|
const existing = mergedHooks[evt] ?? [];
|
|
531
557
|
mergedHooks[evt] = [...existing, ...matchers];
|
|
@@ -929,6 +955,7 @@ export async function runAgent(prompt, opts) {
|
|
|
929
955
|
catch (err) {
|
|
930
956
|
logger.debug({ err }, 'runAgent: subagent backfill failed (non-fatal)');
|
|
931
957
|
}
|
|
958
|
+
const pendingAgentDecision = preconditionGuard.getCapturedDecision();
|
|
932
959
|
return {
|
|
933
960
|
text: finalText,
|
|
934
961
|
totalCostUsd,
|
|
@@ -942,6 +969,7 @@ export async function runAgent(prompt, opts) {
|
|
|
942
969
|
allowedToolsApplied: sdkAllowedTools,
|
|
943
970
|
builtinToolsApplied: toolPolicy.builtinTools,
|
|
944
971
|
mcpServersApplied: Object.keys(mcpServers),
|
|
972
|
+
...(pendingAgentDecision ? { pendingAgentDecision } : {}),
|
|
945
973
|
};
|
|
946
974
|
}
|
|
947
975
|
//# sourceMappingURL=run-agent.js.map
|
package/dist/gateway/router.js
CHANGED
|
@@ -2894,6 +2894,35 @@ export class Gateway {
|
|
|
2894
2894
|
}
|
|
2895
2895
|
clearTimeout(chatTimer);
|
|
2896
2896
|
clearTimeout(hardWallTimer);
|
|
2897
|
+
// ── Pre-emptive blocked-action decision (Commit 3 / 1.18.211) ──
|
|
2898
|
+
// The precondition guard inside runAgent denied a tool call
|
|
2899
|
+
// before it fired (e.g. `netlify deploy` without a linked site).
|
|
2900
|
+
// Stash the decision on the session so the next owner message
|
|
2901
|
+
// is parsed as a decision reply, mirror the question into the
|
|
2902
|
+
// transcript, and respond with the question text. No partial
|
|
2903
|
+
// state was left behind because the tool never ran.
|
|
2904
|
+
const preCallDecision = runAgentResult.pendingAgentDecision;
|
|
2905
|
+
if (preCallDecision && !isBuilderSession) {
|
|
2906
|
+
const state = this.getSession(sessionKey);
|
|
2907
|
+
state.pendingAgentDecision = preCallDecision;
|
|
2908
|
+
const decisionResponse = preCallDecision.question;
|
|
2909
|
+
try {
|
|
2910
|
+
this.assistant.injectContext(effectiveSessionKey, originalText, decisionResponse, {
|
|
2911
|
+
pending: false,
|
|
2912
|
+
model: 'chat',
|
|
2913
|
+
countExchange: true,
|
|
2914
|
+
});
|
|
2915
|
+
}
|
|
2916
|
+
catch (err) {
|
|
2917
|
+
logger.debug({ err }, 'chat: pre-call decision transcript mirror failed (non-fatal)');
|
|
2918
|
+
}
|
|
2919
|
+
logger.info({
|
|
2920
|
+
sessionKey: effectiveSessionKey,
|
|
2921
|
+
classifierId: preCallDecision.context.classifierId,
|
|
2922
|
+
provider: preCallDecision.context.provider,
|
|
2923
|
+
}, 'chat: precondition-guard fired pre-call; routed to PendingAgentDecision');
|
|
2924
|
+
return decisionResponse;
|
|
2925
|
+
}
|
|
2897
2926
|
// Mirror transcript so memory + recall continue working — but
|
|
2898
2927
|
// skip for builder sessions since their turns are spec-drafting,
|
|
2899
2928
|
// not real conversation worth recalling later.
|