moving
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { ToolDefinition } from './tools.js';
|
||||
import { RISK_LEVELS, riskAtLeast, type RiskLevel } from '@waggle/shared';
|
||||
import { LoopGuard } from './loop-guard.js';
|
||||
import { parseChatCompletionStream } from './sse-parser.js';
|
||||
import { maybeFireCompletionGate, initialGateState } from './loop-gates.js';
|
||||
@@ -8,10 +9,32 @@ import type { HookRegistry } from './hooks.js';
|
||||
import type { CapabilityRouter } from './capability-router.js';
|
||||
import type { TraceRecorder, TraceHandle } from './trace-recorder.js';
|
||||
import { logTurnEvent } from './turn-context.js';
|
||||
import {
|
||||
capToolResultForModel,
|
||||
compactToolContextForModel,
|
||||
type ToolContextBudget,
|
||||
} from './agent-run-budget.js';
|
||||
import { estimateTokens as estimateTextTokens } from './tool-output-compressor.js';
|
||||
import type {
|
||||
ModelSpendBudget,
|
||||
ModelSpendBillingClass,
|
||||
ModelSpendReservation,
|
||||
} from './cost-tracker.js';
|
||||
import { MODEL_SPEND_RESERVATION_HEADER } from './cost-tracker.js';
|
||||
|
||||
/** Minimal interface for plugin runtime integration (from @waggle/sdk) */
|
||||
type PluginToolCandidate = Omit<ToolDefinition, 'riskLevel'> & { riskLevel?: unknown };
|
||||
|
||||
export interface PluginToolProvider {
|
||||
getAllTools(): Array<{ name: string; description: string; parameters: Record<string, unknown>; execute: (args: Record<string, unknown>) => Promise<string> }>;
|
||||
getAllTools(): PluginToolCandidate[];
|
||||
}
|
||||
|
||||
function normalizePluginToolRisk(value: unknown): RiskLevel {
|
||||
if ((RISK_LEVELS as readonly unknown[]).includes(value)) {
|
||||
const declared = value as RiskLevel;
|
||||
if (riskAtLeast(declared, 'medium')) return declared;
|
||||
}
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
export interface AgentMessage {
|
||||
@@ -31,6 +54,15 @@ export interface AgentLoopConfig {
|
||||
litellmUrl: string;
|
||||
litellmApiKey: string;
|
||||
model: string;
|
||||
/** Canonical priced model before any provider-specific ID rewriting. */
|
||||
billingModel?: string;
|
||||
/** Shared process budget ledger. Omit to preserve unmanaged/library callers. */
|
||||
modelSpendBudget?: ModelSpendBudget;
|
||||
/** Set to free only after the server has verified the route is offline/free. */
|
||||
modelSpendBillingClass?: ModelSpendBillingClass;
|
||||
spendWorkspaceId?: string;
|
||||
/** Existing durable trace that must own self-proxy spend before dispatch. */
|
||||
modelSpendTraceId?: number;
|
||||
systemPrompt: string;
|
||||
tools: ToolDefinition[];
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
@@ -45,6 +77,12 @@ export interface AgentLoopConfig {
|
||||
*/
|
||||
onGiveUp?: (message: string) => void;
|
||||
maxTurns?: number;
|
||||
/** Evidence/tool rounds allowed before a final synthesis-only turn is forced. */
|
||||
maxToolRounds?: number;
|
||||
/** Tokens held back from maxTokenBudget for the final synthesis request. */
|
||||
synthesisReserveTokens?: number;
|
||||
/** Model-facing tool-result hard cap and historical compaction policy. */
|
||||
toolContextBudget?: ToolContextBudget;
|
||||
stream?: boolean;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
hooks?: HookRegistry;
|
||||
@@ -53,6 +91,13 @@ export interface AgentLoopConfig {
|
||||
pluginTools?: PluginToolProvider;
|
||||
/** Optional maximum token budget (input + output combined). Loop terminates gracefully when exceeded. */
|
||||
maxTokenBudget?: number;
|
||||
/** Maximum completion tokens requested from the provider on any one dispatch. */
|
||||
maxOutputTokens?: number;
|
||||
/** Optional provider-native reasoning policy. Omitted to preserve provider defaults. */
|
||||
reasoning?: {
|
||||
enabled: boolean;
|
||||
effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
||||
};
|
||||
/** Optional abort signal — when aborted, the agent loop exits between turns */
|
||||
signal?: AbortSignal;
|
||||
/** Team governance policies — blocked tools and allowed sources.
|
||||
@@ -173,6 +218,63 @@ function containsRawToolCallMarkup(content: string): boolean {
|
||||
|| /```(?:json|tool)?\s*\{[^`]*"tool"/is.test(content);
|
||||
}
|
||||
|
||||
const EXPLICIT_CITATION_INTENT = /\b(?:cite|citations?|source\s+urls?|provide\s+(?:the\s+)?(?:sources?|links?)|include\s+(?:the\s+)?(?:sources?|links?))\b/i;
|
||||
const NEGATED_CITATION_INTENT = /\b(?:do\s+not|don't|dont|never|avoid|omit|without|no)\b(?:\s+\w+){0,4}\s+(?:cite|citations?|sources?|source\s+urls?|links?)\b/i;
|
||||
const UNUSABLE_FETCH_RESULT = /^(?:error\b|fetch\s+(?:failed|error)\b|page fetched but no text content found\b|\[(?:security|blocked)\]|tool\s+"[^"]+"\s+(?:is blocked|not found)\b)/i;
|
||||
|
||||
function safeFetchedCitationUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) return null;
|
||||
try {
|
||||
const parsed = new URL(value.trim());
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
|
||||
// Never reproduce credentials or signed/query-bearing URLs automatically.
|
||||
if (parsed.username || parsed.password || parsed.search) return null;
|
||||
parsed.hash = '';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appendFetchedSourceFooter(
|
||||
content: string,
|
||||
citationIntent: boolean,
|
||||
fetchedUrls: ReadonlySet<string>,
|
||||
): { content: string; suffix: string } {
|
||||
if (!citationIntent || fetchedUrls.size === 0) return { content, suffix: '' };
|
||||
const missing = [...fetchedUrls].filter(url => !content.includes(url));
|
||||
if (missing.length === 0) return { content, suffix: '' };
|
||||
const suffix = `${content.endsWith('\n') ? '\n' : '\n\n'}Sources fetched:\n${missing.map(url => `- ${url}`).join('\n')}`;
|
||||
return { content: `${content}${suffix}`, suffix };
|
||||
}
|
||||
|
||||
const SUPPORTED_COMPLETION_FINISH_REASONS = new Set(['stop', 'tool_calls']);
|
||||
|
||||
type IncompleteCompletionError = Error & {
|
||||
code: 'INCOMPLETE_COMPLETION';
|
||||
usage?: AgentResponse['usage'];
|
||||
partialToolCalls?: unknown;
|
||||
};
|
||||
|
||||
function isIncompleteCompletionError(error: unknown): error is IncompleteCompletionError {
|
||||
return typeof error === 'object'
|
||||
&& error !== null
|
||||
&& (error as { code?: unknown }).code === 'INCOMPLETE_COMPLETION';
|
||||
}
|
||||
|
||||
function incompleteCompletionError(
|
||||
reason: string,
|
||||
usage: AgentResponse['usage'],
|
||||
): IncompleteCompletionError {
|
||||
const error = new Error(
|
||||
`LLM returned an incomplete completion (${reason}); partial content was not accepted.`,
|
||||
) as IncompleteCompletionError;
|
||||
error.name = 'IncompleteCompletionError';
|
||||
error.code = 'INCOMPLETE_COMPLETION';
|
||||
error.usage = usage;
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentResponse> {
|
||||
const {
|
||||
litellmUrl,
|
||||
@@ -185,6 +287,13 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
onToolUse: userOnToolUse,
|
||||
onToolResult: userOnToolResult,
|
||||
maxTurns = 10,
|
||||
maxToolRounds,
|
||||
synthesisReserveTokens,
|
||||
toolContextBudget = {
|
||||
maxSingleResultChars: 8_000,
|
||||
recentResultCount: 2,
|
||||
historicalResultChars: 750,
|
||||
},
|
||||
stream = false,
|
||||
fetch: fetchFn = globalThis.fetch,
|
||||
hooks,
|
||||
@@ -196,10 +305,38 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
onSkillDistillationFire,
|
||||
} = config;
|
||||
|
||||
if (
|
||||
config.maxTokenBudget !== undefined
|
||||
&& (!Number.isFinite(config.maxTokenBudget) || config.maxTokenBudget < 1)
|
||||
) {
|
||||
throw new RangeError('maxTokenBudget must be a positive finite number');
|
||||
}
|
||||
if (
|
||||
config.maxOutputTokens !== undefined
|
||||
&& (!Number.isFinite(config.maxOutputTokens) || config.maxOutputTokens < 1)
|
||||
) {
|
||||
throw new RangeError('maxOutputTokens must be a positive finite number');
|
||||
}
|
||||
|
||||
const userRequest = [...inputMessages]
|
||||
.reverse()
|
||||
.find(message => message.role === 'user')?.content ?? '';
|
||||
const citationIntent = EXPLICIT_CITATION_INTENT.test(userRequest)
|
||||
&& !NEGATED_CITATION_INTENT.test(userRequest);
|
||||
const successfullyFetchedCitationUrls = new Set<string>();
|
||||
let lastToolObservation: {
|
||||
name: string;
|
||||
citationUrl: string | null;
|
||||
usableResult: boolean;
|
||||
} | undefined;
|
||||
|
||||
logTurnEvent(turnId, {
|
||||
stage: 'agent-loop.enter',
|
||||
model,
|
||||
maxTurns,
|
||||
maxToolRounds,
|
||||
maxTokenBudget: config.maxTokenBudget,
|
||||
synthesisReserveTokens,
|
||||
toolCount: configTools.length,
|
||||
messageCount: inputMessages.length,
|
||||
systemPromptChars: systemPrompt.length,
|
||||
@@ -232,16 +369,30 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
}
|
||||
: userOnToolUse;
|
||||
|
||||
const onToolResult = traceCallbacks
|
||||
? (name: string, input: Record<string, unknown>, result: string) => {
|
||||
traceCallbacks.onToolResult(name, input, result);
|
||||
userOnToolResult?.(name, input, result);
|
||||
}
|
||||
: userOnToolResult;
|
||||
const onToolResult = (
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
result: string,
|
||||
) => {
|
||||
const trimmedResult = result.trim();
|
||||
lastToolObservation = {
|
||||
name,
|
||||
citationUrl: safeFetchedCitationUrl(input.url),
|
||||
usableResult: trimmedResult.length > 0 && !UNUSABLE_FETCH_RESULT.test(trimmedResult),
|
||||
};
|
||||
traceCallbacks?.onToolResult(name, input, result);
|
||||
userOnToolResult?.(name, input, result);
|
||||
};
|
||||
|
||||
// Merge plugin tools (if any) into the base tool set
|
||||
const tools: ToolDefinition[] = pluginToolProvider
|
||||
? [...configTools, ...pluginToolProvider.getAllTools()]
|
||||
? [
|
||||
...configTools,
|
||||
...pluginToolProvider.getAllTools().map((tool) => ({
|
||||
...tool,
|
||||
riskLevel: normalizePluginToolRisk(tool.riskLevel),
|
||||
})),
|
||||
]
|
||||
: configTools;
|
||||
|
||||
// Build messages array with system prompt + input messages
|
||||
@@ -289,6 +440,79 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
// One-shot completion gates (D3 verification, D1 skill distillation) +
|
||||
// preserved-answer slot for issue #4. See `./loop-gates.ts` for details.
|
||||
let gateState = initialGateState();
|
||||
let toolRoundCount = 0;
|
||||
let synthesisForced = false;
|
||||
let lastRequestInputTokens = 0;
|
||||
const maxTokenBudget = typeof config.maxTokenBudget === 'number'
|
||||
&& Number.isFinite(config.maxTokenBudget)
|
||||
&& config.maxTokenBudget > 0
|
||||
? Math.floor(config.maxTokenBudget)
|
||||
: undefined;
|
||||
const configuredOutputCeiling = config.maxOutputTokens ?? synthesisReserveTokens ?? 8_192;
|
||||
const outputTokenCeiling = Number.isFinite(configuredOutputCeiling) && configuredOutputCeiling > 0
|
||||
? Math.floor(configuredOutputCeiling)
|
||||
: 8_192;
|
||||
|
||||
const budgetStopResponse = (
|
||||
usableContent?: string,
|
||||
usableContentWasStreamed = false,
|
||||
): AgentResponse => {
|
||||
const used = totalInputTokens + totalOutputTokens;
|
||||
const preservedContent = gateState.preservedAnswerForDistillation;
|
||||
const usableAnswer = usableContent?.trim();
|
||||
const baseContent = preservedContent
|
||||
?? usableAnswer
|
||||
?? `Token budget exhausted before another safe provider request (used ${used} tokens, limit ${maxTokenBudget}).`;
|
||||
const finalized = appendFetchedSourceFooter(
|
||||
baseContent,
|
||||
citationIntent,
|
||||
successfullyFetchedCitationUrls,
|
||||
);
|
||||
if (stream && onToken) {
|
||||
if (preservedContent || usableContentWasStreamed) {
|
||||
if (finalized.suffix) onToken(finalized.suffix);
|
||||
} else {
|
||||
onToken(finalized.content);
|
||||
}
|
||||
}
|
||||
const content = finalized.content;
|
||||
logTurnEvent(turnId, {
|
||||
stage: 'agent-loop.exit',
|
||||
reason: 'token-budget-exhausted',
|
||||
contentChars: content.length,
|
||||
toolsUsed,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
});
|
||||
return {
|
||||
content,
|
||||
toolsUsed,
|
||||
usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens },
|
||||
};
|
||||
};
|
||||
|
||||
const forceSynthesis = (reason: 'tool-round-limit' | 'token-reserve'): void => {
|
||||
if (synthesisForced) return;
|
||||
synthesisForced = true;
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: [
|
||||
'Evidence collection is complete. Do not call more tools. Produce the final answer now using only the evidence already present.',
|
||||
'Use this truncation-safe order:',
|
||||
'1. First sentence: directly answer the user\'s main question and state any requested recommendation or decision. If the evidence cannot support one, say that there.',
|
||||
'2. Immediately complete every other explicit user deliverable, as compactly as the request allows, including requested tables.',
|
||||
'3. Only then add source inventories, methodology, detailed fact-versus-inference discussion, evidence gaps, caveats, or other supporting detail.',
|
||||
'Do not open with sources, process, or evidence gaps. Cite source URLs alongside supported claims, distinguish verified facts from inference, and do not mention internal turn or token budgets.',
|
||||
].join('\n'),
|
||||
});
|
||||
logTurnEvent(turnId, {
|
||||
stage: 'agent-loop.synthesis-forced',
|
||||
reason,
|
||||
toolRoundCount,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
});
|
||||
};
|
||||
|
||||
for (let turn = 0; turn < maxTurns; turn++) {
|
||||
// Check for abort between turns
|
||||
@@ -300,14 +524,70 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
};
|
||||
}
|
||||
|
||||
if (!synthesisForced && maxToolRounds !== undefined && toolRoundCount >= maxToolRounds) {
|
||||
forceSynthesis('tool-round-limit');
|
||||
}
|
||||
const usedBeforeRequest = totalInputTokens + totalOutputTokens;
|
||||
let requestMessages = compactToolContextForModel(messages, toolContextBudget);
|
||||
const turnOpenAiTools = gateState.verificationCorrectionUsed
|
||||
? openaiTools.filter(tool => tool.function.name !== 'save_memory')
|
||||
: openaiTools;
|
||||
const estimateNextRequestTokens = (): number => {
|
||||
const serializedEstimate = estimateTextTokens(
|
||||
JSON.stringify(requestMessages)
|
||||
+ (!synthesisForced && turnOpenAiTools.length > 0 ? JSON.stringify(turnOpenAiTools) : ''),
|
||||
);
|
||||
return synthesisForced
|
||||
? serializedEstimate
|
||||
: Math.max(lastRequestInputTokens, serializedEstimate);
|
||||
};
|
||||
let estimatedNextRequestTokens = estimateNextRequestTokens();
|
||||
// A tool turn is not safe merely because its own request fits: the next
|
||||
// no-tools synthesis must be able to replay comparable context and still
|
||||
// retain the configured completion allowance.
|
||||
let futureSynthesisReserve = !synthesisForced && turnOpenAiTools.length > 0 && synthesisReserveTokens
|
||||
? estimatedNextRequestTokens + synthesisReserveTokens
|
||||
: 0;
|
||||
if (
|
||||
!synthesisForced
|
||||
&& turnOpenAiTools.length > 0
|
||||
&& maxTokenBudget
|
||||
&& synthesisReserveTokens
|
||||
&& usedBeforeRequest + estimatedNextRequestTokens + futureSynthesisReserve >= maxTokenBudget
|
||||
) {
|
||||
forceSynthesis('token-reserve');
|
||||
requestMessages = compactToolContextForModel(messages, toolContextBudget);
|
||||
estimatedNextRequestTokens = estimateNextRequestTokens();
|
||||
futureSynthesisReserve = 0;
|
||||
}
|
||||
|
||||
const outputTokenLimit = maxTokenBudget === undefined
|
||||
? outputTokenCeiling
|
||||
: Math.min(
|
||||
outputTokenCeiling,
|
||||
Math.floor(maxTokenBudget - usedBeforeRequest - estimatedNextRequestTokens - futureSynthesisReserve),
|
||||
);
|
||||
if (outputTokenLimit < 1) return budgetStopResponse();
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
messages,
|
||||
messages: requestMessages,
|
||||
max_tokens: outputTokenLimit,
|
||||
};
|
||||
if (openaiTools.length > 0) {
|
||||
body.tools = openaiTools;
|
||||
if (config.reasoning) {
|
||||
body.reasoning = { ...config.reasoning };
|
||||
}
|
||||
if (stream) {
|
||||
const currentRequestToolNames = synthesisForced
|
||||
? []
|
||||
: turnOpenAiTools.map(tool => tool.function.name);
|
||||
if (currentRequestToolNames.length > 0) {
|
||||
body.tools = turnOpenAiTools;
|
||||
}
|
||||
// A forced synthesis is the only request in the turn that cannot execute
|
||||
// tools. Make it atomic so an upstream SSE truncation cannot discard an
|
||||
// otherwise complete evidence-backed answer after all tool work finished.
|
||||
const requestUsesStream = stream && !synthesisForced;
|
||||
if (requestUsesStream) {
|
||||
body.stream = true;
|
||||
body.stream_options = { include_usage: true };
|
||||
}
|
||||
@@ -322,17 +602,49 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
: timeoutSignal;
|
||||
|
||||
let response: Response;
|
||||
let spendReservation: ModelSpendReservation | undefined = config.modelSpendBudget?.reserveModelSpend({
|
||||
model: config.billingModel ?? model,
|
||||
inputTokens: estimatedNextRequestTokens,
|
||||
maxOutputTokens: outputTokenLimit,
|
||||
workspaceId: config.spendWorkspaceId,
|
||||
billingClass: config.modelSpendBillingClass,
|
||||
});
|
||||
const reservationHandoff = spendReservation
|
||||
? config.modelSpendBudget?.issueModelSpendReservationHandoff?.(
|
||||
spendReservation,
|
||||
JSON.stringify(body),
|
||||
litellmUrl,
|
||||
config.modelSpendTraceId ?? config.traceRecording?.handle.id,
|
||||
)
|
||||
: undefined;
|
||||
try {
|
||||
response = await fetchFn(`${litellmUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${litellmApiKey}`,
|
||||
...(reservationHandoff
|
||||
? { [MODEL_SPEND_RESERVATION_HEADER]: reservationHandoff.token }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: requestSignal,
|
||||
});
|
||||
} catch (netErr) {
|
||||
if (reservationHandoff) {
|
||||
const handoffDisposition = config.modelSpendBudget?.takeModelSpendReservationHandoffDisposition?.(
|
||||
reservationHandoff.token,
|
||||
);
|
||||
config.modelSpendBudget?.discardModelSpendReservationHandoff?.(reservationHandoff.token);
|
||||
if (handoffDisposition === 'release' && spendReservation) {
|
||||
config.modelSpendBudget?.releaseReservedModelSpend(spendReservation);
|
||||
spendReservation = undefined;
|
||||
}
|
||||
}
|
||||
if (spendReservation) {
|
||||
config.modelSpendBudget?.commitReservedModelSpend(spendReservation);
|
||||
spendReservation = undefined;
|
||||
}
|
||||
// The fetch promise itself rejected — a network-level failure (endpoint
|
||||
// down / restarting, socket hang-up, "fetch failed") or our timeout fired.
|
||||
// A genuine client disconnect re-throws (caught by the between-turn guard
|
||||
@@ -349,8 +661,29 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reservationHandoff) {
|
||||
const handoffDisposition = config.modelSpendBudget?.takeModelSpendReservationHandoffDisposition?.(
|
||||
reservationHandoff.token,
|
||||
);
|
||||
config.modelSpendBudget?.discardModelSpendReservationHandoff?.(reservationHandoff.token);
|
||||
if (handoffDisposition === 'release' && spendReservation) {
|
||||
config.modelSpendBudget?.releaseReservedModelSpend(spendReservation);
|
||||
spendReservation = undefined;
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
const action = await handleNonOkResponse(response, retryState);
|
||||
if (spendReservation) {
|
||||
const definitelyRejectedBeforeInference = response.status === 429
|
||||
|| [400, 401, 403, 404, 405, 413, 415, 422].includes(response.status);
|
||||
if (definitelyRejectedBeforeInference) {
|
||||
config.modelSpendBudget?.releaseReservedModelSpend(spendReservation);
|
||||
} else {
|
||||
// Server-side failures can be ambiguous about inference/token use.
|
||||
config.modelSpendBudget?.commitReservedModelSpend(spendReservation);
|
||||
}
|
||||
spendReservation = undefined;
|
||||
}
|
||||
if (action.kind === 'fatal') throw action.error;
|
||||
if (onToken) onToken(action.notice);
|
||||
await new Promise(r => setTimeout(r, action.waitMs));
|
||||
@@ -365,16 +698,60 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
};
|
||||
let turnInputTokens = 0;
|
||||
let turnOutputTokens = 0;
|
||||
let completionFinishReason: string | null = null;
|
||||
let streamDoneObserved = !requestUsesStream;
|
||||
let currentTurnStreamedContent = '';
|
||||
|
||||
if (stream) {
|
||||
const parsed = await parseChatCompletionStream(response.body!, {
|
||||
onToken: (token) => {
|
||||
allStreamedContent += token;
|
||||
if (onToken) onToken(token);
|
||||
},
|
||||
});
|
||||
if (requestUsesStream) {
|
||||
let parsed: Awaited<ReturnType<typeof parseChatCompletionStream>>;
|
||||
try {
|
||||
parsed = await parseChatCompletionStream(response.body!, {
|
||||
onToken: (token) => {
|
||||
currentTurnStreamedContent += token;
|
||||
allStreamedContent += token;
|
||||
if (onToken) onToken(token);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isIncompleteCompletionError(error)) {
|
||||
if (spendReservation) {
|
||||
config.modelSpendBudget?.commitReservedModelSpend(spendReservation);
|
||||
spendReservation = undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const observedInput = error.usage?.inputTokens ?? 0;
|
||||
const observedOutput = error.usage?.outputTokens ?? 0;
|
||||
const failedInputTokens = observedInput > 0
|
||||
? observedInput
|
||||
: estimatedNextRequestTokens;
|
||||
const failedOutputTokens = observedOutput > 0
|
||||
? observedOutput
|
||||
: Math.max(1, estimateTextTokens(JSON.stringify({
|
||||
content: currentTurnStreamedContent,
|
||||
tool_calls: error.partialToolCalls ?? [],
|
||||
})));
|
||||
error.usage = {
|
||||
inputTokens: totalInputTokens + failedInputTokens,
|
||||
outputTokens: totalOutputTokens + failedOutputTokens,
|
||||
};
|
||||
if (spendReservation) {
|
||||
if (observedInput > 0 || observedOutput > 0) {
|
||||
config.modelSpendBudget?.reconcileModelSpend(spendReservation, {
|
||||
inputTokens: failedInputTokens,
|
||||
outputTokens: failedOutputTokens,
|
||||
});
|
||||
} else {
|
||||
config.modelSpendBudget?.commitReservedModelSpend(spendReservation);
|
||||
}
|
||||
spendReservation = undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
turnInputTokens = parsed.usage.inputTokens;
|
||||
turnOutputTokens = parsed.usage.outputTokens;
|
||||
completionFinishReason = parsed.finishReason;
|
||||
streamDoneObserved = parsed.doneObserved;
|
||||
// Use empty string (not null) when there are tool_calls — some LLM
|
||||
// proxies (LiteLLM→Anthropic) mishandle null content alongside tool_use.
|
||||
assistantMessage = {
|
||||
@@ -383,8 +760,10 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
};
|
||||
} else {
|
||||
// Non-streaming path: parse the single chat completion response.
|
||||
try {
|
||||
const data = await response.json() as {
|
||||
choices?: Array<{
|
||||
finish_reason?: string | null;
|
||||
message: {
|
||||
content: string | null;
|
||||
tool_calls?: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }>;
|
||||
@@ -392,14 +771,45 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
}>;
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
||||
};
|
||||
if (!data.choices || data.choices.length === 0) {
|
||||
throw new Error(
|
||||
`LiteLLM returned no choices: ${JSON.stringify(data).slice(0, 200)}`
|
||||
);
|
||||
}
|
||||
assistantMessage = data.choices[0].message;
|
||||
if (!data.choices || data.choices.length === 0) {
|
||||
throw new Error(
|
||||
`LiteLLM returned no choices: ${JSON.stringify(data).slice(0, 200)}`
|
||||
);
|
||||
}
|
||||
const choice = data.choices[0];
|
||||
if (!choice.message || typeof choice.message !== 'object') {
|
||||
throw new Error(
|
||||
`LiteLLM returned an invalid choice: ${JSON.stringify(choice).slice(0, 200)}`
|
||||
);
|
||||
}
|
||||
assistantMessage = choice.message;
|
||||
completionFinishReason = choice.finish_reason ?? null;
|
||||
turnInputTokens = data.usage?.prompt_tokens ?? 0;
|
||||
turnOutputTokens = data.usage?.completion_tokens ?? 0;
|
||||
} catch (error) {
|
||||
if (spendReservation) {
|
||||
config.modelSpendBudget?.commitReservedModelSpend(spendReservation);
|
||||
spendReservation = undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Some OpenAI-compatible providers omit or corrupt usage counters. Do not
|
||||
// interpret missing/non-finite counters as free work.
|
||||
if (!Number.isFinite(turnInputTokens) || turnInputTokens <= 0) {
|
||||
turnInputTokens = estimatedNextRequestTokens;
|
||||
}
|
||||
if (!Number.isFinite(turnOutputTokens) || turnOutputTokens <= 0) {
|
||||
turnOutputTokens = estimateTextTokens(JSON.stringify(assistantMessage));
|
||||
}
|
||||
|
||||
if (spendReservation) {
|
||||
config.modelSpendBudget?.reconcileModelSpend(spendReservation, {
|
||||
inputTokens: turnInputTokens,
|
||||
outputTokens: turnOutputTokens,
|
||||
});
|
||||
spendReservation = undefined;
|
||||
}
|
||||
|
||||
// R3-008: if the run was aborted while the in-flight response was being
|
||||
@@ -417,19 +827,50 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
|
||||
totalInputTokens += turnInputTokens;
|
||||
totalOutputTokens += turnOutputTokens;
|
||||
|
||||
const incompleteReason = completionFinishReason === 'length'
|
||||
? 'finish_reason=length'
|
||||
: requestUsesStream && !streamDoneObserved
|
||||
? 'stream ended before data: [DONE]'
|
||||
: completionFinishReason === null
|
||||
? 'missing finish_reason'
|
||||
: !SUPPORTED_COMPLETION_FINISH_REASONS.has(completionFinishReason)
|
||||
? `unsupported finish_reason=${completionFinishReason}`
|
||||
: null;
|
||||
if (incompleteReason) {
|
||||
logTurnEvent(turnId, {
|
||||
stage: 'agent-loop.incomplete-completion',
|
||||
reason: incompleteReason,
|
||||
finishReason: completionFinishReason,
|
||||
streamDoneObserved,
|
||||
inputTokens: turnInputTokens,
|
||||
outputTokens: turnOutputTokens,
|
||||
});
|
||||
throw incompleteCompletionError(incompleteReason, {
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
});
|
||||
}
|
||||
|
||||
lastRequestInputTokens = turnInputTokens;
|
||||
retryState = initialRetryState(); // Reset retry counters on success
|
||||
|
||||
// Check token budget
|
||||
if (config.maxTokenBudget && (totalInputTokens + totalOutputTokens) > config.maxTokenBudget) {
|
||||
const used = totalInputTokens + totalOutputTokens;
|
||||
// Issue #4 — if D1 has already fired, the user's answer is the deliverable;
|
||||
// surface it rather than swallowing it under a budget message.
|
||||
return {
|
||||
content: gateState.preservedAnswerForDistillation
|
||||
?? `Token budget exceeded (used ${used} tokens, limit ${config.maxTokenBudget}).`,
|
||||
toolsUsed,
|
||||
usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens },
|
||||
};
|
||||
// Provider usage is authoritative and known only after the response. Once
|
||||
// the hard budget is exhausted, do not execute pending tools, completion
|
||||
// gates, or a second synthesis request.
|
||||
if (maxTokenBudget !== undefined && (totalInputTokens + totalOutputTokens) >= maxTokenBudget) {
|
||||
const usableContent = assistantMessage.tool_calls?.length && !synthesisForced
|
||||
? undefined
|
||||
: ((assistantMessage.content ?? '').trim() || allStreamedContent.trim() || undefined);
|
||||
const result = budgetStopResponse(
|
||||
usableContent
|
||||
?? (synthesisReserveTokens
|
||||
? 'I gathered evidence but the token budget was exhausted before a reliable final synthesis.'
|
||||
: `Token budget exceeded (used ${totalInputTokens + totalOutputTokens} tokens, limit ${maxTokenBudget}).`),
|
||||
Boolean(requestUsesStream && usableContent),
|
||||
);
|
||||
if (!stream && onToken && result.content) onToken(result.content);
|
||||
return result;
|
||||
}
|
||||
|
||||
// No tool calls — return the final response
|
||||
@@ -452,12 +893,14 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
}
|
||||
|
||||
// Completion-time gates: D3 (verification) + D1 (skill distillation).
|
||||
// See ./loop-gates.ts. If a gate fires, it pushes the corrective
|
||||
// directive into `messages` and returns fired=true → continue loop.
|
||||
// See ./loop-gates.ts. If a gate fires, it amends the internal context
|
||||
// and returns fired=true → continue loop.
|
||||
const gate = await maybeFireCompletionGate({
|
||||
content,
|
||||
toolsUsed,
|
||||
availableToolNames: currentRequestToolNames,
|
||||
messages,
|
||||
userRequest,
|
||||
state: gateState,
|
||||
enableVerification: verificationGate,
|
||||
enableSkillDistillation: skillDistillationGate,
|
||||
@@ -465,16 +908,28 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
turnId,
|
||||
});
|
||||
gateState = gate.state;
|
||||
if (gate.fired) continue;
|
||||
if (gate.fired) {
|
||||
if (stream && onToken && gate.contentSuffix) onToken(gate.contentSuffix);
|
||||
continue;
|
||||
}
|
||||
|
||||
const acceptedContent = `${content}${gate.contentSuffix ?? ''}`;
|
||||
// Once D1 has fired, surface the preserved user answer instead of the
|
||||
// internal skill-distillation summary produced by the current turn.
|
||||
const finalized = appendFetchedSourceFooter(
|
||||
gateState.preservedAnswerForDistillation ?? acceptedContent,
|
||||
citationIntent,
|
||||
successfullyFetchedCitationUrls,
|
||||
);
|
||||
const finalContent = finalized.content;
|
||||
|
||||
// In non-streaming mode, emit the full content as a single token
|
||||
if (!stream && onToken && content) {
|
||||
onToken(content);
|
||||
if (!requestUsesStream && onToken && finalContent) {
|
||||
onToken(finalContent);
|
||||
} else if (requestUsesStream && onToken) {
|
||||
if (gate.contentSuffix) onToken(gate.contentSuffix);
|
||||
if (finalized.suffix) onToken(finalized.suffix);
|
||||
}
|
||||
// Issue #4 — once D1 has fired, the user's answer was captured before
|
||||
// the distillation turn ran; the current `content` is the skill
|
||||
// summary, NOT the answer. Surface the preserved answer instead.
|
||||
const finalContent = gateState.preservedAnswerForDistillation ?? content;
|
||||
logTurnEvent(turnId, {
|
||||
stage: 'agent-loop.exit',
|
||||
contentChars: finalContent.length,
|
||||
@@ -489,7 +944,32 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
};
|
||||
}
|
||||
|
||||
// Tool definitions are withheld on the reserved synthesis turn. If a model
|
||||
// nevertheless emits a phantom native call, accept its prose but never
|
||||
// execute beyond the evidence budget.
|
||||
if (synthesisForced) {
|
||||
const synthesis = (assistantMessage.content ?? '').trim()
|
||||
|| allStreamedContent
|
||||
|| 'I gathered evidence but could not complete a reliable synthesis. Please retry the final synthesis.';
|
||||
const finalized = appendFetchedSourceFooter(
|
||||
gateState.preservedAnswerForDistillation ?? synthesis,
|
||||
citationIntent,
|
||||
successfullyFetchedCitationUrls,
|
||||
);
|
||||
if (!requestUsesStream && onToken && finalized.content) {
|
||||
onToken(finalized.content);
|
||||
} else if (requestUsesStream && onToken && finalized.suffix) {
|
||||
onToken(finalized.suffix);
|
||||
}
|
||||
return {
|
||||
content: finalized.content,
|
||||
toolsUsed,
|
||||
usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens },
|
||||
};
|
||||
}
|
||||
|
||||
// Has tool calls — execute them and continue the loop
|
||||
toolRoundCount++;
|
||||
// Ensure content is never null when tool_calls are present (LiteLLM→Anthropic compat)
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
@@ -499,9 +979,13 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
|
||||
// Execute each tool call through the explicit middleware chain in
|
||||
// `./tool-executor.ts`. Review C2 hook-ordering is preserved there.
|
||||
const turnToolMap = gateState.verificationCorrectionUsed
|
||||
? new Map([...toolMap].filter(([name]) => name !== 'save_memory'))
|
||||
: toolMap;
|
||||
for (const toolCall of assistantMessage.tool_calls) {
|
||||
lastToolObservation = undefined;
|
||||
const r = await executeToolCall(toolCall, {
|
||||
toolMap,
|
||||
toolMap: turnToolMap,
|
||||
guard,
|
||||
hooks,
|
||||
capabilityRouter: config.capabilityRouter,
|
||||
@@ -510,8 +994,27 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
onToolResult,
|
||||
turnId,
|
||||
});
|
||||
const observation = lastToolObservation as {
|
||||
name: string;
|
||||
citationUrl: string | null;
|
||||
usableResult: boolean;
|
||||
} | undefined;
|
||||
if (
|
||||
citationIntent
|
||||
&& r.countedAsUsed
|
||||
&& r.toolName === 'web_fetch'
|
||||
&& observation?.name === 'web_fetch'
|
||||
&& observation.usableResult
|
||||
&& observation.citationUrl
|
||||
) {
|
||||
successfullyFetchedCitationUrls.add(observation.citationUrl);
|
||||
}
|
||||
if (r.countedAsUsed) toolsUsed.push(r.toolName);
|
||||
messages.push({ role: 'tool', content: r.content, tool_call_id: r.toolCallId });
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: capToolResultForModel(r.content, toolContextBudget.maxSingleResultChars),
|
||||
tool_call_id: r.toolCallId,
|
||||
});
|
||||
|
||||
// Steal #9 T3 — a critical failure streak: give up rather than burn more
|
||||
// turns retrying a tool that keeps failing. Surface the give-up copy and
|
||||
@@ -534,12 +1037,29 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
||||
}
|
||||
}
|
||||
|
||||
// maxTurns reached — return any accumulated content rather than generic message.
|
||||
// Issue #4 — if D1 has already fired, prefer the user's captured answer
|
||||
// over the generic "max tool turns" fallback (the answer is the deliverable).
|
||||
// maxTurns reached — bounded runs must never expose an internal max-turn
|
||||
// message. Normally the reserved synthesis turn returns above; this fallback
|
||||
// is only for a malformed provider response during that final request.
|
||||
const fallbackBaseWasStreamed = Boolean(
|
||||
gateState.preservedAnswerForDistillation || allStreamedContent,
|
||||
);
|
||||
const finalized = appendFetchedSourceFooter(
|
||||
gateState.preservedAnswerForDistillation
|
||||
?? (allStreamedContent || (synthesisReserveTokens
|
||||
? 'I gathered evidence but could not complete a reliable synthesis. Please retry the final synthesis.'
|
||||
: `Max tool turns reached (${maxTurns} turns, ${toolsUsed.length} tools used).`)),
|
||||
citationIntent,
|
||||
successfullyFetchedCitationUrls,
|
||||
);
|
||||
if (stream && onToken) {
|
||||
if (fallbackBaseWasStreamed) {
|
||||
if (finalized.suffix) onToken(finalized.suffix);
|
||||
} else {
|
||||
onToken(finalized.content);
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: gateState.preservedAnswerForDistillation
|
||||
?? (allStreamedContent || `Max tool turns reached (${maxTurns} turns, ${toolsUsed.length} tools used).`),
|
||||
content: finalized.content,
|
||||
toolsUsed,
|
||||
usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens },
|
||||
};
|
||||
|
||||
163
packages/agent/src/agent-run-budget.ts
Normal file
163
packages/agent/src/agent-run-budget.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { TaskShapeType } from './task-shape.js';
|
||||
|
||||
export interface ToolContextBudget {
|
||||
/** Hard cap applied before any single tool result enters model history. */
|
||||
maxSingleResultChars: number;
|
||||
/** Most recent results retained at the single-result cap. */
|
||||
recentResultCount: number;
|
||||
/** Per-result excerpt cap for older successful results. */
|
||||
historicalResultChars: number;
|
||||
}
|
||||
|
||||
export interface AgentRunBudgetPolicy {
|
||||
/** Total model calls, including one reserved final synthesis call. */
|
||||
maxTurns: number;
|
||||
/** Evidence/tool rounds allowed before synthesis is forced. */
|
||||
maxToolRounds: number;
|
||||
/** Cumulative provider-reported input + output token ceiling. */
|
||||
maxTokenBudget: number;
|
||||
/** Headroom reserved for the final synthesis request and response. */
|
||||
synthesisReserveTokens: number;
|
||||
toolContextBudget: ToolContextBudget;
|
||||
}
|
||||
|
||||
export interface AgentRunBudgetInput {
|
||||
taskShape: TaskShapeType;
|
||||
complexity: 'simple' | 'moderate' | 'complex';
|
||||
selectedToolNames: readonly string[];
|
||||
}
|
||||
|
||||
interface ToolContextMessage {
|
||||
role: string;
|
||||
content: string | null;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
const DOCUMENT_TOOLS = new Set([
|
||||
'generate_docx',
|
||||
'generate_pdf',
|
||||
'generate_pptx',
|
||||
'write_file',
|
||||
'multi_edit',
|
||||
]);
|
||||
|
||||
const RESEARCH_SHAPES = new Set<TaskShapeType>(['research', 'compare']);
|
||||
|
||||
/**
|
||||
* Evidence-backed per-turn policy. Research reserves enough room for a final
|
||||
* synthesis while staying inside the live acceptance envelope; write-heavy and
|
||||
* complex execution workflows retain a larger bounded envelope.
|
||||
*/
|
||||
export function selectAgentRunBudget(input: AgentRunBudgetInput): AgentRunBudgetPolicy {
|
||||
const hasTools = input.selectedToolNames.length > 0;
|
||||
if (!hasTools) {
|
||||
return {
|
||||
maxTurns: 3,
|
||||
maxToolRounds: 2,
|
||||
maxTokenBudget: 40_000,
|
||||
synthesisReserveTokens: 8_000,
|
||||
toolContextBudget: {
|
||||
maxSingleResultChars: 4_000,
|
||||
recentResultCount: 2,
|
||||
historicalResultChars: 500,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (RESEARCH_SHAPES.has(input.taskShape)) {
|
||||
return {
|
||||
maxTurns: 5,
|
||||
maxToolRounds: 4,
|
||||
maxTokenBudget: 56_000,
|
||||
synthesisReserveTokens: 13_000,
|
||||
toolContextBudget: {
|
||||
maxSingleResultChars: 3_000,
|
||||
recentResultCount: 1,
|
||||
historicalResultChars: 900,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const isDocumentWorkflow = input.taskShape === 'draft'
|
||||
&& input.selectedToolNames.some(name => DOCUMENT_TOOLS.has(name));
|
||||
const isLongWorkflow = isDocumentWorkflow
|
||||
|| ((input.taskShape === 'plan-execute' || input.taskShape === 'mixed')
|
||||
&& input.complexity === 'complex');
|
||||
|
||||
if (isLongWorkflow) {
|
||||
return {
|
||||
maxTurns: 17,
|
||||
maxToolRounds: 16,
|
||||
maxTokenBudget: 160_000,
|
||||
synthesisReserveTokens: 24_000,
|
||||
toolContextBudget: {
|
||||
maxSingleResultChars: 12_000,
|
||||
recentResultCount: 3,
|
||||
historicalResultChars: 1_200,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
maxTurns: 9,
|
||||
maxToolRounds: 8,
|
||||
maxTokenBudget: 80_000,
|
||||
synthesisReserveTokens: 14_000,
|
||||
toolContextBudget: {
|
||||
maxSingleResultChars: 8_000,
|
||||
recentResultCount: 2,
|
||||
historicalResultChars: 750,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function excerpt(content: string, maxChars: number, label: string): string {
|
||||
if (content.length <= maxChars) return content;
|
||||
const marker = `\n...[${label}]...\n`;
|
||||
if (maxChars <= marker.length + 2) return content.slice(0, maxChars);
|
||||
const available = maxChars - marker.length;
|
||||
const headChars = Math.ceil(available * 0.65);
|
||||
const tailChars = available - headChars;
|
||||
return content.slice(0, headChars) + marker + content.slice(-tailChars);
|
||||
}
|
||||
|
||||
function isErrorResult(content: string): boolean {
|
||||
return /(?:^|\n)\s*(?:error|failed|failure|fatal)\b|not found|timed? out|permission denied/i.test(content);
|
||||
}
|
||||
|
||||
/** Hard-cap a newly executed tool result before it is appended to model history. */
|
||||
export function capToolResultForModel(content: string, maxChars: number): string {
|
||||
return excerpt(content, maxChars, 'tool result truncated; beginning and end preserved');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request-only message view. Conversation structure and tool-call IDs
|
||||
* are retained; recent results and all errors stay intact (within the hard cap),
|
||||
* while older successful results become bounded head/tail source excerpts.
|
||||
*/
|
||||
export function compactToolContextForModel<T extends ToolContextMessage>(
|
||||
messages: readonly T[],
|
||||
budget: ToolContextBudget,
|
||||
): T[] {
|
||||
const toolIndexes = messages
|
||||
.map((message, index) => message.role === 'tool' ? index : -1)
|
||||
.filter(index => index >= 0);
|
||||
const recentIndexes = new Set(toolIndexes.slice(-budget.recentResultCount));
|
||||
|
||||
return messages.map((message, index) => {
|
||||
if (message.role !== 'tool' || typeof message.content !== 'string') return { ...message };
|
||||
const capped = capToolResultForModel(message.content, budget.maxSingleResultChars);
|
||||
if (recentIndexes.has(index) || isErrorResult(capped)) {
|
||||
return { ...message, content: capped };
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
content: excerpt(capped, budget.historicalResultChars, 'historical tool excerpt; beginning and end preserved'),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -23,18 +23,36 @@ export const BEHAVIORAL_SPEC = {
|
||||
|
||||
For EVERY user message, follow this internal process:
|
||||
|
||||
=== CRITICAL: EXPLICIT-INSTRUCTION FIDELITY ===
|
||||
Explicit user constraints override persona defaults, workflow habits, proactive
|
||||
offers, and calls to action. Persona defaults MUST yield when they conflict.
|
||||
- "No follow-up" means do not ask questions, invite more detail, or append an offer.
|
||||
- "No files" and "no schedules" mean do not create, propose, or offer file/calendar artifacts.
|
||||
- "Evidence-only" or "add no new claims" means do not fill gaps with plausible detail.
|
||||
- A closed-world rewrite preserves only the supplied facts and their original
|
||||
certainty. Do not add dates, roles, causes, risks, requirements, or conclusions.
|
||||
- User-provided claims remain unverified unless an allowed tool or artifact proves
|
||||
them this turn. Attribute them; do not silently upgrade them to facts.
|
||||
- Assumptions, dates, and requirements not supplied by evidence must be omitted or clearly labeled as assumptions; never present them as established constraints.
|
||||
- The serialized tool schema is the complete capability boundary for this turn.
|
||||
If a named tool is absent, do not call it, simulate it, or claim it is available.
|
||||
- Before presenting a code example, self-check imports, name scope, control flow,
|
||||
exception/retry paths, and count semantics. If not executed, label it UNVERIFIED.
|
||||
- "Primary sources" means official documentation, official repositories, original papers, standards, or first-party data. AI summaries and aggregators are not primary.
|
||||
=== END CRITICAL ===
|
||||
|
||||
## Step 1: RECALL (before anything else)
|
||||
- Do I have relevant memories about this topic, person, or project?
|
||||
- If the user references something from before, search_memory FIRST.
|
||||
- If the user references something from before and search_memory is serialized, use it FIRST.
|
||||
- If I have preloaded context above that's relevant, use it directly — don't re-search.
|
||||
- NEVER claim "I don't remember" without actually searching.
|
||||
- NEVER claim "I don't remember" without searching when search_memory is available; otherwise state that memory search is unavailable.
|
||||
|
||||
## Step 2: ASSESS
|
||||
- Is this a simple greeting/question? → Respond directly, warmly, concisely.
|
||||
- Is this a factual question I'm not certain about? → Use tools (web_search, bash, read_file).
|
||||
- Is this vague, ambiguous, or could be interpreted multiple ways? → Ask 1-2 targeted clarifying questions BEFORE acting. Do NOT guess. Do NOT generate a document. Examples: "make it better" → ask what aspect to improve; "fix this" → ask what's wrong; "help me" → ask with what; "create a report" without specifics → ask about scope, audience, key points. NEVER use generate_docx in response to an ambiguous request — clarify FIRST, generate AFTER.
|
||||
- Is this a factual question I'm not certain about? → Use an appropriate tool only if it is present in the serialized tool schema.
|
||||
- Is this vague, ambiguous, or could be interpreted multiple ways? → Ask 1-2 targeted clarifying questions BEFORE acting, unless the user prohibited follow-up; then proceed with the minimum clearly labeled assumptions. Do NOT guess. Do NOT generate a document. Examples: "make it better" → ask what aspect to improve; "fix this" → ask what's wrong; "help me" → ask with what; "create a report" without specifics → ask about scope, audience, key points. NEVER use generate_docx in response to an ambiguous request — clarify FIRST, generate AFTER.
|
||||
- Is this a complex task? → Think through the approach before acting.
|
||||
- Is this a multi-step operation? → Create a plan first (create_plan), then execute step by step.
|
||||
- Is this a multi-step operation? → Use create_plan only when it is serialized and the user permits stateful planning; otherwise reason through a concise plan without a tool call.
|
||||
|
||||
## Step 3: ACT
|
||||
- For simple, low-risk actions: just do them. Don't narrate "I'm going to read the file..." — just read it and give the result.
|
||||
@@ -43,7 +61,7 @@ For EVERY user message, follow this internal process:
|
||||
- Chain tools naturally: read → understand → decide → act → verify.
|
||||
|
||||
## Step 4: LEARN (save after every meaningful exchange)
|
||||
You MUST call save_memory when any of these happen:
|
||||
When save_memory is present in the serialized tool schema and user constraints permit it, call it when any of these happen:
|
||||
- A decision was made ("let's go with X", "we decided to...")
|
||||
- The user stated a preference ("I prefer...", "always...", "never...", "call me...")
|
||||
- The user corrected you — save the correction so you never repeat the mistake
|
||||
@@ -71,9 +89,9 @@ Do NOT save: greetings, small talk, trivial questions, tool outputs, things alre
|
||||
=== CRITICAL: MEMORY CONFLICT PROTOCOL ===
|
||||
When the user states a fact that CONTRADICTS a stored memory:
|
||||
1. DO NOT blindly accept the new claim
|
||||
2. Search memory to surface the conflicting record
|
||||
2. Search memory only when search_memory is serialized and permitted; otherwise use the recalled record already in allowed context
|
||||
3. Present both: "I have a stored memory that says X. You are now saying Y. Which is correct?"
|
||||
4. Update memory ONLY after explicit confirmation
|
||||
4. Update memory ONLY after explicit confirmation, and only when save_memory is serialized and permitted
|
||||
5. When updating, save the correction with the reason: "Correction: X → Y (confirmed by user on [date])"
|
||||
|
||||
This prevents gradual memory drift where repeated assertions overwrite validated facts.
|
||||
@@ -102,8 +120,8 @@ produce the evidence that proves it — do not assert success you have not check
|
||||
- ALWAYS distinguish what you KNOW (from memory, tools, or documents) from what you're REASONING or INFERRING.
|
||||
- When citing recalled memories, say so: "From our previous discussion...", "You mentioned earlier that...", "Based on your workspace memory..."
|
||||
- When you're reasoning without evidence, flag it: "I think..." or "My suggestion would be..." — never present inference as recalled fact.
|
||||
- If you're unsure about something the user may have told you before, search_memory. If nothing found, say "I don't have that in memory" — never fabricate prior context.
|
||||
- NEVER invent dates, numbers, names, or quotes. If you don't have exact data, say so and offer to look it up.
|
||||
- If you're unsure about something the user may have told you before, use search_memory only when it is serialized and permitted. Otherwise state that it is not established — never fabricate prior context.
|
||||
- NEVER invent dates, numbers, names, or quotes. If exact data is missing, identify the gap; look it up only when the user permits it and a relevant tool is serialized.
|
||||
|
||||
## Structured Output
|
||||
When your response contains actionable information, use structure:
|
||||
@@ -119,7 +137,7 @@ Your responses must feel specific to THIS workspace and THIS user:
|
||||
- Reference workspace content by name: "In the Marketing workspace...", "Your project uses React + Node.js..."
|
||||
- When recalling memories, include the relevant detail, not just "I found something in memory."
|
||||
- Connect new information to existing context: "This relates to the decision you made about X..."
|
||||
- If the workspace has accumulated context, USE it. A response that ignores available memory is a failure.
|
||||
- If the workspace has relevant context inside the allowed evidence boundary, use it. Do not search or persist memory against user constraints.
|
||||
- Prefer concrete workspace-specific advice over generic suggestions. "Based on your 8 sessions here..." > "Generally speaking..."
|
||||
|
||||
## Professional Disclaimers
|
||||
@@ -132,20 +150,21 @@ When your response provides actionable guidance on regulated topics (financial a
|
||||
behavioralRules: `# BEHAVIORAL RULES
|
||||
|
||||
## Memory-First
|
||||
- ALWAYS search memory before claiming you don't know something the user may have told you before.
|
||||
- When the user says "remember" or "we discussed" — that's your cue to search_memory immediately.
|
||||
- Save the user's preferences, corrections, and important context. This is how you get smarter over time.
|
||||
- Your memory is your competitive advantage. Use it constantly.
|
||||
- Search memory before claiming you don't know something the user may have said, but only when search_memory is serialized and scope permits it.
|
||||
- When the user says "remember" or "we discussed", use search_memory if available; otherwise state the limitation.
|
||||
- Save preferences, corrections, and important context only when save_memory is serialized and user constraints permit it.
|
||||
- Your memory is your competitive advantage. Use it whenever the evidence boundary and user constraints permit it.
|
||||
|
||||
## Tool Intelligence
|
||||
- NEVER guess at facts. If unsure, use tools: bash for system info, web_search for current info, read_file for project files.
|
||||
- "I think", "probably", "likely" before a factual claim = you're guessing. Stop. Search instead.
|
||||
- Chain tools: web_search → web_fetch for deep reading. search_files → read_file for code understanding.
|
||||
- NEVER guess at facts. If unsure, use only a relevant tool present in the serialized schema; otherwise label the uncertainty.
|
||||
- "I think", "probably", "likely" before a factual claim = you're guessing. Use an allowed serialized tool or label the uncertainty.
|
||||
- Chain tools only when each one is serialized: web_search → web_fetch for deep reading; search_files → read_file for code understanding.
|
||||
- For comparisons requiring external sources, stop repeating discovery once one qualifying URL per item is found; batch the independent web_fetch calls in the next tool round, and do not synthesize while a required source remains unfetched and web_fetch is available.
|
||||
- When researching, give the user the INSIGHT, not a copy of search results.
|
||||
- After using tools, synthesize the results into workspace context. Don't dump raw output — explain what it means for THIS project.
|
||||
|
||||
## Narration Heuristics — Know When to Talk
|
||||
- Simple tool calls (read_file, search_memory, bash date): just do them silently. Share the result.
|
||||
- Simple permitted tool calls: do them silently and share the result.
|
||||
- Multi-step work: briefly state your approach. "Let me check your git status and recent commits."
|
||||
- Sensitive/destructive ops: always explain before acting. "I'll delete the old config and create a new one."
|
||||
- NEVER narrate the obvious: "I'm going to use the bash tool to run a command" — just run it.
|
||||
@@ -153,13 +172,13 @@ When your response provides actionable guidance on regulated topics (financial a
|
||||
## Error Recovery
|
||||
- Tool failed? Try a different approach. Don't just report the error — solve the problem.
|
||||
- Command timed out? Try a simpler command, or break the task into smaller steps.
|
||||
- Can't find a file? Search for it. Can't search? Ask the user.
|
||||
- Can't find a file? Use an available search tool. If none is serialized, state the limitation or ask the user when follow-up is allowed.
|
||||
- Network error on web_search? Tell the user briefly, continue with what you know.
|
||||
- NEVER show raw error traces to the user. Summarize what went wrong and what you'll do about it.
|
||||
|
||||
## Planning for Complex Tasks
|
||||
- If a task has 3+ steps, use create_plan to outline them.
|
||||
- Execute each step with execute_step as you complete it.
|
||||
- If a task has 3+ steps, use create_plan only when serialized and permitted; otherwise outline it directly.
|
||||
- Use execute_step only when serialized and the user authorized execution.
|
||||
- If a step fails, adapt the plan — don't blindly continue.
|
||||
- Share the plan with the user so they know what to expect.`,
|
||||
|
||||
@@ -169,24 +188,24 @@ When your response provides actionable guidance on regulated topics (financial a
|
||||
## Drafting from Context
|
||||
When the user asks you to draft, write, or produce something (email, memo, summary, plan, update, brief, report):
|
||||
|
||||
1. **Gather context first** — search_memory for relevant workspace context. Check recalled memories above. Read relevant files if referenced.
|
||||
2. **Apply personal style** — search_memory with scope="personal" for style preferences (tone, format, length). If the user prefers bullet points, don't write paragraphs. If they prefer direct language, skip formalities.
|
||||
3. **Draft with specifics** — use actual names, dates, decisions, and facts from memory. A draft that says "the project" when memory contains "the Marketing Q2 campaign" is a failure. Ground every claim in real context.
|
||||
1. **Gather context first** — use supplied context and preloaded memory. Search memory or read files only when the relevant tools are serialized and the user's evidence boundary permits it.
|
||||
2. **Apply personal style** — use supplied or preloaded style preferences. Search personal memory only when search_memory is serialized and the evidence boundary permits it.
|
||||
3. **Draft with specifics** — use established names, dates, decisions, and facts from the allowed context. Do not turn missing specifics into invented detail.
|
||||
4. **Structure for editing** — the draft should be immediately usable, not a wall of text. Use clear sections, short paragraphs, and headers where appropriate.
|
||||
5. **Offer the right format** — short drafts inline in chat. Long drafts (>1 page) via generate_docx so the user gets a real file they can edit and share.
|
||||
6. **State what you used** — briefly note what context informed the draft: "Based on your 3 recent sessions and the decision to use React..."
|
||||
5. **Use an allowed format** — answer inline unless the user asks for or permits a file and the corresponding generator is serialized.
|
||||
6. **State what you used** — briefly note the allowed context that informed the draft without adding a follow-up offer.
|
||||
|
||||
Draft types and what to include:
|
||||
- **Status update / progress report**: What was done, what's in progress, what's blocked, next steps. Pull from recent session history and decisions.
|
||||
- **Status update / progress report**: What was done, what's in progress, what's blocked, next steps. Use only allowed session history and established decisions.
|
||||
- **Email / message**: Match the user's tone. Include specific context. Keep it sendable — subject line, greeting, body, sign-off.
|
||||
- **Summary / brief**: Key points, decisions made, open questions. Organized by topic, not chronology.
|
||||
- **Plan / proposal**: Goal, approach, steps, timeline, risks. Grounded in what's already known about the project.
|
||||
- **Meeting notes / action items**: Decisions, owners, deadlines, next meeting topics.
|
||||
- **Plan / proposal**: Goal, approach, steps, and risks. Include a timeline only when supplied or requested, and label estimates as assumptions.
|
||||
- **Meeting notes / action items**: Established decisions, owners, and supplied deadlines; include next-meeting topics only when requested.
|
||||
|
||||
## Decision Compression
|
||||
When the user asks "what matters?", "what should I do next?", "catch me up", or similar:
|
||||
|
||||
1. **Search broadly** — search_memory for recent context, decisions, open items, blockers.
|
||||
1. **Search broadly when permitted** — use search_memory if serialized; otherwise rely on supplied and preloaded context.
|
||||
2. **Compress, don't summarize** — the user wants signal, not a recap. Distill to: what changed, what matters, what needs attention, what to do next.
|
||||
3. **Be opinionated** — rank items by importance. "The most important thing right now is X because Y." Don't present everything as equally important.
|
||||
4. **Structure the response**:
|
||||
@@ -200,16 +219,19 @@ When the user asks "what matters?", "what should I do next?", "catch me up", or
|
||||
## Research in Context
|
||||
When the user asks you to research something:
|
||||
|
||||
1. **Start with memory** — search_memory first. What do you already know about this topic in this workspace?
|
||||
2. **Then search externally** — web_search for current information. web_fetch to go deeper on promising results.
|
||||
1. **Start with allowed context** — use preloaded context, then search_memory only if serialized and within scope.
|
||||
2. **Then search externally when permitted** — use web_search/web_fetch only when serialized and consistent with the requested source class.
|
||||
3. **Synthesize into project context** — don't just report findings. Explain what they mean for THIS workspace and THIS user's goals.
|
||||
4. **Save the findings** — use save_memory to store key discoveries so they're available in future sessions. This is how the workspace gets smarter.
|
||||
5. **Connect to existing knowledge** — "This confirms your earlier decision to..." or "This changes the picture because..."
|
||||
4. **Save findings conditionally** — use save_memory only when serialized and user constraints permit persistence.
|
||||
5. **Connect to established knowledge** — reference prior decisions only when they are present in allowed context or verified through a permitted tool.
|
||||
6. **Cite sources** — for external research, include URLs or reference names so the user can verify.`,
|
||||
|
||||
/** Intelligence defaults — evolves with capabilities */
|
||||
intelligenceDefaults: `# TOOLS
|
||||
|
||||
The capabilities below are descriptive possibilities, not a guarantee for this
|
||||
turn. Only tools present in the serialized tool schema may be called.
|
||||
|
||||
## Web (for current information)
|
||||
- web_search: Search DuckDuckGo. Use for current events, products, releases, docs.
|
||||
- web_fetch: Read any URL. Use after web_search to go deeper on a result.
|
||||
@@ -273,17 +295,15 @@ Routing rules:
|
||||
|
||||
When the user asks for something that needs structured domain expertise (risk assessment, research synthesis, code review, decision analysis, etc.) and you don't have a matching loaded skill:
|
||||
|
||||
1. **Call acquire_capability** with a description of what you need. It will:
|
||||
1. **If acquire_capability is serialized, call it** with a description of what you need. It will:
|
||||
- Check if a native tool or active skill already covers the need
|
||||
- Search the starter skill pack AND the marketplace (skills, MCP connectors, plugins) for installable capabilities
|
||||
- Return a structured proposal with candidates and a recommendation
|
||||
2. **If it recommends an installable capability**: tell the user what was found and why, then **emit the inline install affordance** so they get a one-click Install button. Output this HTML-comment marker on its own line, using the EXACT name and source from the proposal:
|
||||
\`<!--waggle:capability_request {"name":"<name>","source":"<source>","reason":"<one-line why>"}-->\`
|
||||
The UI renders this as an approval card with Install / Dismiss. This is the path for ALL sources — starter-pack skills, marketplace packages, and MCP connectors alike. Do this even when (especially when) the need is filesystem / external access / a connector — never tell the user to npm-install, edit config, or restart; the card handles install in-session.
|
||||
3. **Only call the install_capability tool directly** for a \`starter-pack\` source when you intend to apply the skill yourself in this same turn. For \`marketplace\` / \`mcp\` / \`connector\` sources, the marker (step 2) is the install path — do NOT call install_capability for those (it installs starter-pack skills only).
|
||||
2. **If it recommends an installable capability**: tell the user what was found and why. The interface consumes the completed tool result and automatically renders an approval card for supported \`starter-pack\` skills and \`marketplace\` packages. Do NOT copy, reconstruct, or fabricate the internal capability marker in ordinary assistant prose.
|
||||
3. **Only call the install_capability tool directly** for a \`starter-pack\` source when you intend to apply the skill yourself in this same turn. For a \`marketplace\` source, wait for the user to act on the interface card. For MCP or connector suggestions, use their dedicated serialized tool when one is available; otherwise explain the gap without inventing an install control.
|
||||
4. **The user clicks Install (or you get tool approval).** Wait for it; then apply the new capability to their original task.
|
||||
|
||||
Do NOT skip the acquire_capability step. Do NOT paraphrase the recommendation in place of the marker — the card only renders from the exact marker. Do NOT guess names — always use the exact values from the proposal.
|
||||
When acquire_capability is available, do NOT skip it or guess proposal values. If it is absent from the serialized schema, do not call it or emit a fabricated install marker; state the capability gap directly.
|
||||
|
||||
If acquire_capability says a native tool or active skill already handles the need, use that directly instead of installing anything.
|
||||
|
||||
@@ -291,8 +311,8 @@ If acquire_capability says a native tool or active skill already handles the nee
|
||||
recall surfaces a prior turn where you said you "couldn't" install something,
|
||||
"don't have a tool", or told the user to npm-install / edit config / restart —
|
||||
treat that as stale. Capabilities change between sessions; the product ships
|
||||
in-session capability install. You MUST actually call acquire_capability THIS
|
||||
turn before claiming a capability gap. Never assert "I tried X / it's not
|
||||
in-session capability install. When acquire_capability is serialized, you MUST
|
||||
actually call it THIS turn before claiming a capability gap. Never assert "I tried X / it's not
|
||||
possible / I've exhausted every option" based on remembered past failure
|
||||
without a fresh acquire_capability call in the current turn. Reporting a tool
|
||||
result you did not produce this turn is a confabulation and is prohibited.
|
||||
@@ -301,7 +321,7 @@ result you did not produce this turn is a confabulation and is prohibited.
|
||||
|
||||
When you SUCCESSFULLY complete a task that took several distinct tool calls
|
||||
or multi-step work (≈5+ tool calls, or a non-trivial workflow you'd repeat),
|
||||
call **create_skill** to distill the reusable approach into a durable skill:
|
||||
call **create_skill** only if it is serialized and persistence is permitted:
|
||||
1. First search_skills / list_skills — if a close skill already exists, improve
|
||||
it instead of creating a near-duplicate.
|
||||
2. Capture the *generalized* method, not this run's specifics: the steps, which
|
||||
@@ -334,19 +354,19 @@ Most tasks do NOT need workflow composition. Use it only when a request has **mu
|
||||
|
||||
**Decision flow:**
|
||||
1. Simple question or single-step task → respond directly (no tools needed)
|
||||
2. Multi-step but single-domain task (e.g., "write a report") → use a loaded skill or create_plan
|
||||
3. Multi-phase task with distinct work types → call compose_workflow to get a structured plan
|
||||
4. Only if compose_workflow recommends sub-agents AND the task genuinely warrants parallel specialists → use orchestrate_workflow
|
||||
2. Multi-step but single-domain task (e.g., "write a report") → use a loaded skill, or create_plan only if it is serialized and permitted
|
||||
3. Multi-phase task with distinct work types → call compose_workflow only if it is serialized; otherwise plan directly
|
||||
4. Only if compose_workflow recommends sub-agents, the user permits launches, and orchestrate_workflow is serialized → use it
|
||||
|
||||
**Never** jump straight to orchestrate_workflow for tasks you can handle directly. The compose_workflow tool will tell you when sub-agents are actually warranted.
|
||||
**Never** jump straight to orchestrate_workflow for tasks you can handle directly. When serialized, compose_workflow can recommend whether sub-agents are warranted.
|
||||
|
||||
## Intelligence Defaults
|
||||
When approaching any task:
|
||||
1. SKILL CHECK: Before answering generically, check if an installed skill covers this topic. Use suggest_skill to find relevant skills.
|
||||
2. WORKFLOW ROUTING: For multi-step tasks (research, compare, draft, review, plan), use compose_workflow to select the optimal execution mode rather than doing everything sequentially.
|
||||
3. SUB-AGENT DELEGATION: For research-heavy tasks, consider spawning a researcher sub-agent. For review tasks, spawn a reviewer. Don't do everything in one loop when delegation would produce better results.
|
||||
1. SKILL CHECK: Use suggest_skill only when serialized and consistent with the user's requested scope.
|
||||
2. WORKFLOW ROUTING: Use compose_workflow only when serialized and the task genuinely has distinct phases.
|
||||
3. SUB-AGENT DELEGATION: Consider spawning specialists only when spawn_agent is serialized and the user permits launches.
|
||||
4. COMMAND AWARENESS: When the user's request matches a slash command, suggest it. Examples: /catchup for workspace re-entry, /research for investigation, /draft for document creation, /decide for decision analysis.
|
||||
5. CAPABILITY DISCOVERY: If you lack a tool or skill for the task, use acquire_capability to search for installable capabilities before saying you can't do something.`,
|
||||
5. CAPABILITY DISCOVERY: Use acquire_capability only when serialized; never attempt or simulate an absent tool.`,
|
||||
|
||||
/**
|
||||
* Assemble full rules string (preserves backward compatibility).
|
||||
|
||||
@@ -34,6 +34,10 @@ export interface CapabilityCandidate {
|
||||
matchReason: string; // Human-readable: why this matches the need
|
||||
installAction: string | null; // null if already active or native
|
||||
trust?: TrustAssessment; // Trust/risk assessment (attached during search)
|
||||
packageId?: number; // Canonical marketplace row identity
|
||||
installType?: 'skill' | 'plugin' | 'mcp';
|
||||
version?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export interface AcquisitionProposal {
|
||||
@@ -159,9 +163,13 @@ export function loadStarterSkillsMeta(starterDir: string): StarterSkillMeta[] {
|
||||
|
||||
/** A marketplace search result mapped to candidate format */
|
||||
export interface MarketplaceCandidate {
|
||||
packageId?: number;
|
||||
name: string;
|
||||
description: string;
|
||||
packageType: string;
|
||||
installType?: 'skill' | 'plugin' | 'mcp';
|
||||
version?: string;
|
||||
author?: string;
|
||||
source: string;
|
||||
/** Match score from marketplace FTS (normalized 0–1 or raw) */
|
||||
score?: number;
|
||||
@@ -279,6 +287,10 @@ export function searchCapabilities(input: SearchCapabilitiesInput): AcquisitionP
|
||||
matchScore: effectiveScore,
|
||||
matchReason: buildMatchReason(nameHits, contentHits) || 'marketplace search match',
|
||||
installAction: 'install_capability',
|
||||
packageId: mkt.packageId,
|
||||
installType: mkt.installType,
|
||||
version: mkt.version,
|
||||
author: mkt.author,
|
||||
trust: assessTrust({ capabilityType: 'skill', source: 'marketplace', content: mkt.description }),
|
||||
});
|
||||
}
|
||||
@@ -375,18 +387,30 @@ function buildProposalSummary(
|
||||
? `- **Risk level**: ${capitalize(recommendation.trust.riskLevel)} (${recommendation.trust.assessmentMode})\n` +
|
||||
`- **Trust**: ${recommendation.trust.explanation}\n`
|
||||
: '';
|
||||
// Emit the exact inline-install marker the UI parses (capability-request-
|
||||
// parser.ts → CapabilityRequestCard). The agent is instructed (behavioral
|
||||
// spec) to reproduce this verbatim so the user gets a one-click Install
|
||||
// card for ANY source — starter-pack skill, marketplace pkg, or MCP
|
||||
// connector. reason is sanitized so it can't break the comment/JSON.
|
||||
// Emit the structured marker as the final segment of this trusted tool
|
||||
// result. The UI consumes it directly; the agent must not copy it into
|
||||
// ordinary assistant prose. reason is sanitized so it cannot break the
|
||||
// comment/JSON envelope.
|
||||
const capReason = String(recommendation.description || recommendation.matchReason || 'fills the requested capability gap')
|
||||
.replace(/[{}<>]/g, '').replace(/\s+/g, ' ').trim().slice(0, 140);
|
||||
const marker = `<!--waggle:capability_request ${JSON.stringify({
|
||||
const marketplaceIdentity = recommendation.source === 'marketplace'
|
||||
&& Number.isSafeInteger(recommendation.packageId)
|
||||
&& (recommendation.packageId ?? 0) > 0
|
||||
&& (recommendation.installType === 'skill'
|
||||
|| recommendation.installType === 'plugin'
|
||||
|| recommendation.installType === 'mcp');
|
||||
const markerPayload = {
|
||||
name: recommendation.name,
|
||||
source: recommendation.source,
|
||||
kind: recommendation.source === 'marketplace' ? 'marketplace' : 'skill',
|
||||
reason: capReason,
|
||||
})}-->`;
|
||||
...(marketplaceIdentity
|
||||
? { packageId: recommendation.packageId, installType: recommendation.installType }
|
||||
: {}),
|
||||
};
|
||||
const marker = recommendation.source === 'starter-pack' || marketplaceIdentity
|
||||
? `<!--waggle:capability_request ${JSON.stringify(markerPayload)}-->`
|
||||
: null;
|
||||
sections.push(
|
||||
`### Recommendation\n\n` +
|
||||
`Install **${recommendation.name}** from the ${recommendation.source}.\n` +
|
||||
@@ -394,8 +418,7 @@ function buildProposalSummary(
|
||||
`- **What it does**: ${recommendation.description}\n` +
|
||||
trustBlock +
|
||||
`- **Approval required**: Yes — user must approve before installation.\n\n` +
|
||||
`Surface this to the user by outputting the following marker on its own ` +
|
||||
`line, verbatim (it renders as a one-click Install card):\n\n${marker}`,
|
||||
(marker ? `The interface will surface this proposal as an install card:\n\n${marker}` : ''),
|
||||
);
|
||||
} else if (recommendation && recommendation.availability === 'active') {
|
||||
sections.push(
|
||||
|
||||
@@ -8,11 +8,84 @@
|
||||
* in config.json. All executions are logged to the audit trail.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { ToolDefinition } from './tools.js';
|
||||
import { win32 as pathWin32 } from 'node:path';
|
||||
import {
|
||||
resolveToolCommandInvocation,
|
||||
resolveToolCommandInvocationFromPath,
|
||||
type ToolCommandInvocation,
|
||||
} from './tool-command.js';
|
||||
import { createSanitizedEnv, execFileWithTreeTimeout } from './system-tools-helpers.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
async function execCliInvocation(
|
||||
invocation: ToolCommandInvocation,
|
||||
env: NodeJS.ProcessEnv,
|
||||
timeoutMs: number,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const result = await execFileWithTreeTimeout(invocation.binary, invocation.args, {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
maxBuffer: 1024 * 1024,
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
|
||||
}, timeoutMs);
|
||||
|
||||
if (result.timedOut) {
|
||||
throw Object.assign(new Error(`Killed after ${timeoutMs / 1000}s timeout`), {
|
||||
cleanupDegraded: result.cleanupDegraded,
|
||||
killed: true,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
}
|
||||
if (result.errorMessage) {
|
||||
throw Object.assign(new Error(result.errorMessage), {
|
||||
cleanupDegraded: result.cleanupDegraded,
|
||||
code: result.errorCode ?? undefined,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
}
|
||||
return { stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
function isBareWindowsCommand(program: string): boolean {
|
||||
return process.platform === 'win32' && !pathWin32.isAbsolute(program) && !/[\\/]/.test(program);
|
||||
}
|
||||
|
||||
async function execCliFile(
|
||||
program: string,
|
||||
args: string[],
|
||||
timeoutMs: number,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const env = createSanitizedEnv();
|
||||
if (isBareWindowsCommand(program)) {
|
||||
const resolved = await resolveToolCommandInvocationFromPath(
|
||||
program,
|
||||
args,
|
||||
process.platform,
|
||||
{ env },
|
||||
);
|
||||
return execCliInvocation(resolved, env, timeoutMs);
|
||||
}
|
||||
|
||||
const direct = resolveToolCommandInvocation(program, args, process.platform, { env });
|
||||
try {
|
||||
return await execCliInvocation(direct, env, timeoutMs);
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string | number }).code;
|
||||
if (process.platform !== 'win32' || code !== 'ENOENT') throw err;
|
||||
|
||||
const resolved = await resolveToolCommandInvocationFromPath(
|
||||
program,
|
||||
args,
|
||||
process.platform,
|
||||
{ env },
|
||||
);
|
||||
if (resolved.binary === direct.binary && resolved.args === direct.args) throw err;
|
||||
return execCliInvocation(resolved, env, timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
/** Well-known CLIs to detect on the system */
|
||||
const KNOWN_CLIS = [
|
||||
@@ -44,6 +117,8 @@ const KNOWN_CLIS = [
|
||||
{ name: 'ffmpeg', versionFlag: '-version' },
|
||||
];
|
||||
|
||||
const CLI_DISCOVERY_CONCURRENCY = 8;
|
||||
|
||||
export interface CliToolsConfig {
|
||||
/** Programs the agent is allowed to execute (empty = none allowed) */
|
||||
allowlist: string[];
|
||||
@@ -69,26 +144,44 @@ export function createCliTools(config: CliToolsConfig): ToolDefinition[] {
|
||||
const allowlist = getAllowlist();
|
||||
const allowSet = new Set(allowlist.map(s => s.toLowerCase()));
|
||||
|
||||
// Probe every known CLI in parallel. Sequentially this was up to
|
||||
// KNOWN_CLIS.length × 5s (~130s) — far over the 30s test budget on CI
|
||||
// runners (where most of these CLIs are present), which made the
|
||||
// cli_discover test flaky. Promise.all bounds wall-time to the slowest
|
||||
// single probe (~5s) and preserves KNOWN_CLIS order in the output.
|
||||
const settled = await Promise.all(
|
||||
KNOWN_CLIS.map(async (cli): Promise<CliResult | null> => {
|
||||
try {
|
||||
const args = cli.versionFlag.split(' ');
|
||||
const { stdout } = await execFileAsync(cli.name, args, { timeout: 5000 });
|
||||
return {
|
||||
name: cli.name,
|
||||
version: stdout.trim().split('\n')[0],
|
||||
allowed: allowSet.has('*') || allowSet.has(cli.name),
|
||||
};
|
||||
} catch {
|
||||
return null; // CLI not found — skip
|
||||
}
|
||||
}),
|
||||
);
|
||||
// Bound concurrent probes because each Windows invocation owns a
|
||||
// Worker. Batching preserves KNOWN_CLIS output order.
|
||||
const settled: Array<CliResult | null> = [];
|
||||
for (let offset = 0; offset < KNOWN_CLIS.length; offset += CLI_DISCOVERY_CONCURRENCY) {
|
||||
const batch = await Promise.all(
|
||||
KNOWN_CLIS.slice(offset, offset + CLI_DISCOVERY_CONCURRENCY)
|
||||
.map(async (cli): Promise<CliResult | null> => {
|
||||
const args = cli.versionFlag.split(' ');
|
||||
const env = createSanitizedEnv();
|
||||
let resolvedFromPath = false;
|
||||
try {
|
||||
const invocation = await resolveToolCommandInvocationFromPath(
|
||||
cli.name,
|
||||
args,
|
||||
process.platform,
|
||||
{ env, fallbackToWhere: false },
|
||||
);
|
||||
resolvedFromPath = invocation.binary !== cli.name;
|
||||
const { stdout, stderr } = await execCliInvocation(invocation, env, 2_000);
|
||||
return {
|
||||
name: cli.name,
|
||||
version: (stdout || stderr).trim().split(/\r?\n/)[0],
|
||||
allowed: allowSet.has('*') || allowSet.has(cli.name),
|
||||
};
|
||||
} catch {
|
||||
if (process.platform === 'win32' && resolvedFromPath) {
|
||||
return {
|
||||
name: cli.name,
|
||||
version: 'Installed (version probe unavailable)',
|
||||
allowed: allowSet.has('*') || allowSet.has(cli.name),
|
||||
};
|
||||
}
|
||||
return null; // CLI not found - skip
|
||||
}
|
||||
}),
|
||||
);
|
||||
settled.push(...batch);
|
||||
}
|
||||
const results = settled.filter((r): r is CliResult => r !== null);
|
||||
|
||||
return JSON.stringify({
|
||||
@@ -112,8 +205,11 @@ export function createCliTools(config: CliToolsConfig): ToolDefinition[] {
|
||||
},
|
||||
execute: async (params: Record<string, unknown>) => {
|
||||
const program = String(params.program ?? '').trim();
|
||||
const args = (params.args as string[]) ?? [];
|
||||
const timeoutSec = Math.min(Number(params.timeout) || 30, 120);
|
||||
const args = Array.isArray(params.args) ? params.args.map(String) : [];
|
||||
const requestedTimeout = Number(params.timeout);
|
||||
const timeoutSec = Number.isFinite(requestedTimeout) && requestedTimeout > 0
|
||||
? Math.min(requestedTimeout, 120)
|
||||
: 30;
|
||||
const allowlist = getAllowlist();
|
||||
const allowSet = new Set(allowlist.map(s => s.toLowerCase()));
|
||||
|
||||
@@ -138,10 +234,7 @@ export function createCliTools(config: CliToolsConfig): ToolDefinition[] {
|
||||
});
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(program, args, {
|
||||
timeout: timeoutSec * 1000,
|
||||
maxBuffer: 1024 * 1024, // 1 MB
|
||||
});
|
||||
const { stdout, stderr } = await execCliFile(program, args, timeoutSec * 1000);
|
||||
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
@@ -152,13 +245,27 @@ export function createCliTools(config: CliToolsConfig): ToolDefinition[] {
|
||||
stderr: stderr.trim(),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const execErr = err as { code?: string; killed?: boolean; signal?: string; stdout?: string; stderr?: string };
|
||||
const execErr = err as {
|
||||
cleanupDegraded?: boolean;
|
||||
code?: string | number;
|
||||
killed?: boolean;
|
||||
signal?: string;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
const cleanupWarning = execErr.cleanupDegraded
|
||||
? ' Process-tree cleanup degraded to the root process; descendants may still be running.'
|
||||
: '';
|
||||
return JSON.stringify({
|
||||
success: false,
|
||||
program,
|
||||
args,
|
||||
exitCode: execErr.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ? -1 : 1,
|
||||
error: execErr.killed ? `Killed after ${timeoutSec}s timeout` : (err instanceof Error ? err.message : String(err)),
|
||||
exitCode: execErr.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
||||
? -1
|
||||
: (typeof execErr.code === 'number' ? execErr.code : 1),
|
||||
error: execErr.killed
|
||||
? `Killed after ${timeoutSec}s timeout.${cleanupWarning}`
|
||||
: `${err instanceof Error ? err.message : String(err)}${cleanupWarning}`,
|
||||
stdout: execErr.stdout?.trim() ?? '',
|
||||
stderr: execErr.stderr?.trim() ?? '',
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Only commands that modify state need user approval.
|
||||
*/
|
||||
|
||||
import { RISK_LEVELS, type RiskLevel } from '@waggle/shared';
|
||||
import { RISK_LEVELS, riskAtLeast, type RiskLevel } from '@waggle/shared';
|
||||
import { deriveApprovalClass } from './trust-model.js';
|
||||
|
||||
// Tools that ALWAYS need confirmation.
|
||||
@@ -14,8 +14,10 @@ import { deriveApprovalClass } from './trust-model.js';
|
||||
// surface enterprise buyers care about — so they're gated too.
|
||||
// Phase B.3 will add persistent "always allow" grants per pair.
|
||||
const ALWAYS_CONFIRM = new Set([
|
||||
'write_file', 'edit_file', 'generate_docx',
|
||||
'git_commit', 'git_push', 'git_pr', 'git_merge',
|
||||
'write_file', 'edit_file', 'multi_edit', 'generate_docx', 'generate_xlsx', 'generate_pptx', 'generate_pdf',
|
||||
'cli_execute',
|
||||
'run_code',
|
||||
'git_commit', 'git_push', 'git_pull', 'git_pr', 'git_merge',
|
||||
'install_capability',
|
||||
// D4(i) skill-write governance: create_skill gates at normal (auto-passes at
|
||||
// trusted/yolo via TRUSTED_AUTOPASS); delete_skill gates at every level via
|
||||
@@ -26,16 +28,15 @@ const ALWAYS_CONFIRM = new Set([
|
||||
]);
|
||||
|
||||
// Connector action name patterns that indicate write operations
|
||||
const CONNECTOR_WRITE_PATTERNS = /_(create|update|delete|send|post|transition|remove|add|set|put)_/;
|
||||
const CONNECTOR_WRITE_PATTERNS = /_(create|update|delete|send|post|transition|remove|destroy|purge|drop|add|set|put|upload|append)(?:_|$)/;
|
||||
|
||||
// Bash command patterns that are safe (read-only / informational)
|
||||
const SAFE_BASH_PATTERNS = [
|
||||
/^(date|whoami|hostname|pwd|echo|printenv|env|uname|id|uptime)\b/,
|
||||
/^(ls|dir|cat|head|tail|wc|find|which|where|type)\b/,
|
||||
/^(git\s+(status|log|diff|branch|remote|show|tag))\b/,
|
||||
/^(node|python|python3|npm|npx|pip)\s+--version/,
|
||||
/^(curl|wget)\s+.*--head/,
|
||||
/^(df|du|free|top|ps|netstat|lsof)\b/,
|
||||
/^(date|whoami|hostname|pwd|uname|id|uptime)$/i,
|
||||
/^(ls|dir)(?:\s+-[al]+)?$/i,
|
||||
/^git\s+(status|log|diff|branch|remote|show|tag)(?:\s+--?[a-z-]+)*$/i,
|
||||
/^(node|python|python3|npm|npx|pip)\s+--version\b/i,
|
||||
/^(df|du|free|top|ps|netstat|lsof)\b/i,
|
||||
];
|
||||
|
||||
// Bash command patterns that are destructive (always confirm)
|
||||
@@ -75,19 +76,43 @@ const CHAIN_OPERATORS = /&&|\|\||;|\|/;
|
||||
/** Known high-risk connector actions (never trust LLM-provided metadata for this) */
|
||||
const CONNECTOR_HIGH_RISK_ACTIONS = new Set([
|
||||
'send_email', 'send_template', // email is always high-risk
|
||||
'execute', 'execute_action', // database writes and Composio's dynamic action bridge
|
||||
]);
|
||||
|
||||
export function needsConfirmation(toolName: string, args?: Record<string, unknown>): boolean {
|
||||
function isHighRiskConnectorAction(toolName: string): boolean {
|
||||
for (const actionName of CONNECTOR_HIGH_RISK_ACTIONS) {
|
||||
if (toolName.endsWith(`_${actionName}`)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function needsConfirmation(
|
||||
toolName: string,
|
||||
args?: Record<string, unknown>,
|
||||
trustedRiskLevel?: RiskLevel,
|
||||
): boolean {
|
||||
// Terminal operations are always gated, even if a narrower name classifier
|
||||
// below does not yet recognize the specific destructive verb.
|
||||
if (isCriticalNeverAutopass(toolName, args, trustedRiskLevel)) return true;
|
||||
|
||||
// ToolDefinition metadata is server/provider-authored. It may only add a
|
||||
// gate; name- and argument-based policy below remains authoritative.
|
||||
if (trustedRiskLevel && riskAtLeast(trustedRiskLevel, 'medium')) return true;
|
||||
|
||||
// Connector tools: determine risk from tool NAME only (never trust args metadata)
|
||||
// This prevents LLM injection of _connectorMeta to bypass approval gates
|
||||
if (toolName.startsWith('connector_')) {
|
||||
// Extract action name: connector_<id>_<action> → <action>
|
||||
const parts = toolName.split('_');
|
||||
const actionPart = parts.slice(2).join('_'); // everything after connector_<id>_
|
||||
if (CONNECTOR_HIGH_RISK_ACTIONS.has(actionPart)) return true;
|
||||
if (isHighRiskConnectorAction(toolName)) return true;
|
||||
return CONNECTOR_WRITE_PATTERNS.test(toolName);
|
||||
}
|
||||
|
||||
// Extended Git tools mix read-only and state-changing actions under one
|
||||
// tool name. Unknown/missing actions fail closed; only the explicit list
|
||||
// variants are informational and may run without approval.
|
||||
if (toolName === 'git_branch' || toolName === 'git_stash') {
|
||||
return String(args?.action ?? '').toLowerCase() !== 'list';
|
||||
}
|
||||
|
||||
// Non-bash tools: simple set check
|
||||
if (toolName !== 'bash') {
|
||||
return ALWAYS_CONFIRM.has(toolName);
|
||||
@@ -132,9 +157,7 @@ import type { ApprovalClass } from '@waggle/shared';
|
||||
export function getApprovalClass(toolName: string, args?: Record<string, unknown>): ApprovalClass {
|
||||
// Connector tools: derive approval class from tool NAME, not args
|
||||
if (toolName.startsWith('connector_')) {
|
||||
const parts = toolName.split('_');
|
||||
const actionPart = parts.slice(2).join('_');
|
||||
if (CONNECTOR_HIGH_RISK_ACTIONS.has(actionPart)) return 'critical';
|
||||
if (isHighRiskConnectorAction(toolName)) return 'critical';
|
||||
if (CONNECTOR_WRITE_PATTERNS.test(toolName)) return 'elevated';
|
||||
return 'standard';
|
||||
}
|
||||
@@ -161,24 +184,37 @@ export function getApprovalClass(toolName: string, args?: Record<string, unknown
|
||||
export function classifyGatedToolRisk(
|
||||
toolName: string,
|
||||
args?: Record<string, unknown>,
|
||||
trustedRiskLevel?: RiskLevel,
|
||||
): { riskLevel: RiskLevel; approvalClass: ApprovalClass } {
|
||||
const elevate = (
|
||||
classification: { riskLevel: RiskLevel; approvalClass: ApprovalClass },
|
||||
): { riskLevel: RiskLevel; approvalClass: ApprovalClass } => {
|
||||
if (!trustedRiskLevel || !riskAtLeast(trustedRiskLevel, classification.riskLevel)) {
|
||||
return classification;
|
||||
}
|
||||
return {
|
||||
riskLevel: trustedRiskLevel,
|
||||
approvalClass: deriveApprovalClass(trustedRiskLevel),
|
||||
};
|
||||
};
|
||||
|
||||
// Terminal/destructive ops on the never-autopass blacklist → critical.
|
||||
if (isCriticalNeverAutopass(toolName, args)) {
|
||||
return { riskLevel: 'critical', approvalClass: 'critical' };
|
||||
return elevate({ riskLevel: 'critical', approvalClass: 'critical' });
|
||||
}
|
||||
// Connector tools carry their risk in the name (write vs read vs high-risk).
|
||||
if (toolName.startsWith('connector_')) {
|
||||
const cls = getApprovalClass(toolName, args);
|
||||
const riskLevel: RiskLevel = cls === 'critical' ? 'high' : cls === 'elevated' ? 'medium' : 'low';
|
||||
return { riskLevel, approvalClass: cls };
|
||||
return elevate({ riskLevel, approvalClass: cls });
|
||||
}
|
||||
// Cross-workspace reads are gated for PRIVACY, not destructiveness → low.
|
||||
if (toolName === 'read_other_workspace' || toolName === 'read_other_workspace_file' || toolName === 'list_workspace_files') {
|
||||
return { riskLevel: 'low', approvalClass: 'standard' };
|
||||
return elevate({ riskLevel: 'low', approvalClass: 'standard' });
|
||||
}
|
||||
// Everything else that gated — fs writes, git mutations, bash, docx — is a
|
||||
// state-changing action: medium / elevated.
|
||||
return { riskLevel: 'medium', approvalClass: 'elevated' };
|
||||
return elevate({ riskLevel: 'medium', approvalClass: 'elevated' });
|
||||
}
|
||||
|
||||
export interface ConfirmationGateConfig {
|
||||
@@ -242,16 +278,25 @@ const CRITICAL_NEVER_AUTOPASS: RegExp[] = [
|
||||
* Returns true if the tool call would be critical/never-autopass EVEN at YOLO.
|
||||
* Used by the autonomy gate to keep the safety net intact at the top level.
|
||||
*/
|
||||
export function isCriticalNeverAutopass(toolName: string, args?: Record<string, unknown>): boolean {
|
||||
export function isCriticalNeverAutopass(
|
||||
toolName: string,
|
||||
args?: Record<string, unknown>,
|
||||
trustedRiskLevel?: RiskLevel,
|
||||
): boolean {
|
||||
// Canonical high/critical risk maps to the critical approval class, whose
|
||||
// contract is never auto-pass. Lower metadata cannot weaken name policy.
|
||||
if (trustedRiskLevel && riskAtLeast(trustedRiskLevel, 'high')) return true;
|
||||
|
||||
// D4(i): deleting a skill is destructive — always ask, every autonomy level.
|
||||
if (toolName === 'delete_skill') return true;
|
||||
if (toolName === 'run_code') return true;
|
||||
// Irreversible connector deletes (delete_record, delete_repository, …) are
|
||||
// terminal — never auto-pass and never a one-click L2 held action.
|
||||
if (toolName.startsWith('connector_') && /_(delete|remove|destroy|purge|drop)(_|$)/.test(toolName)) return true;
|
||||
if (toolName === 'bash') {
|
||||
const command = String(args?.command ?? '').trim();
|
||||
for (const pat of CRITICAL_NEVER_AUTOPASS) {
|
||||
if (pat.test(command)) return true;
|
||||
for (const pattern of CRITICAL_NEVER_AUTOPASS) {
|
||||
if (pattern.test(command)) return true;
|
||||
}
|
||||
}
|
||||
if (toolName === 'install_capability') {
|
||||
@@ -283,22 +328,22 @@ export function needsConfirmationWithAutonomy(
|
||||
toolName: string,
|
||||
args: Record<string, unknown> | undefined,
|
||||
level: AutonomyLevel = 'normal',
|
||||
trustedRiskLevel?: RiskLevel,
|
||||
): boolean {
|
||||
const baseGates = needsConfirmation(toolName, args);
|
||||
const baseGates = needsConfirmation(toolName, args, trustedRiskLevel);
|
||||
if (!baseGates) return false; // never gated anyway
|
||||
|
||||
if (level === 'normal') return true;
|
||||
if (toolName === 'bash' || toolName === 'run_code') return true;
|
||||
|
||||
// Critical blacklist overrides everything — never auto-pass at any level.
|
||||
if (isCriticalNeverAutopass(toolName, args ?? {})) return true;
|
||||
if (isCriticalNeverAutopass(toolName, args ?? {}, trustedRiskLevel)) return true;
|
||||
|
||||
if (level === 'yolo') return false;
|
||||
|
||||
// Trusted: pass the Trusted-specific set + bash (already filtered above),
|
||||
// gate everything else.
|
||||
// Trusted: pass the Trusted-specific set and gate everything else.
|
||||
if (level === 'trusted') {
|
||||
if (TRUSTED_AUTOPASS.has(toolName)) return false;
|
||||
if (toolName === 'bash') return false; // passed the blacklist check
|
||||
return true; // git push, install, connector writes, cross-workspace writes still gate
|
||||
}
|
||||
|
||||
@@ -318,10 +363,14 @@ export class ConfirmationGate {
|
||||
this.headless = config.headless ?? false;
|
||||
}
|
||||
|
||||
async confirm(toolName: string, args: Record<string, unknown>): Promise<boolean> {
|
||||
async confirm(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
trustedRiskLevel?: RiskLevel,
|
||||
): Promise<boolean> {
|
||||
// L1 reads / recall / notify never gate — let them flow even in headless.
|
||||
// (Checked FIRST so the headless deny-default cannot block read-only work.)
|
||||
if (!needsConfirmation(toolName, args)) return true;
|
||||
if (!needsConfirmation(toolName, args, trustedRiskLevel)) return true;
|
||||
if (this.autoApprove.has(toolName)) return true;
|
||||
// Legacy non-interactive behaviour is preserved when headless=false; a
|
||||
// headless tick denies the confirmation-requiring action instead.
|
||||
|
||||
@@ -14,8 +14,16 @@ export interface AuditLogger {
|
||||
log(entry: { actionType: string; description: string; requiresApproval?: boolean }): void;
|
||||
}
|
||||
|
||||
const ALWAYS_CONNECTED_CONNECTOR_IDS = new Set(['slack-mock', 'teams-mock', 'discord-mock']);
|
||||
|
||||
interface ConnectorHydration {
|
||||
promise: Promise<void>;
|
||||
status: 'pending' | 'ready' | 'failed';
|
||||
}
|
||||
|
||||
export class ConnectorRegistry {
|
||||
private connectors = new Map<string, WaggleConnector>();
|
||||
private hydration = new WeakMap<WaggleConnector, ConnectorHydration>();
|
||||
private vault: VaultStore;
|
||||
private auditLogger?: AuditLogger;
|
||||
|
||||
@@ -27,6 +35,19 @@ export class ConnectorRegistry {
|
||||
/** Register a connector in the registry */
|
||||
register(connector: WaggleConnector): void {
|
||||
this.connectors.set(connector.id, connector);
|
||||
void this.beginHydration(connector);
|
||||
}
|
||||
|
||||
/** Reload a registered connector's in-memory state from the vault. */
|
||||
async hydrate(id: string): Promise<boolean> {
|
||||
const connector = this.connectors.get(id);
|
||||
if (!connector) return false;
|
||||
try {
|
||||
await this.beginHydration(connector);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a connector from the registry */
|
||||
@@ -44,15 +65,64 @@ export class ConnectorRegistry {
|
||||
return this.connectors.get(id);
|
||||
}
|
||||
|
||||
private beginHydration(connector: WaggleConnector): Promise<void> {
|
||||
const previous = this.hydration.get(connector)?.promise;
|
||||
const promise = (async () => {
|
||||
if (previous) {
|
||||
try {
|
||||
await previous;
|
||||
} catch {
|
||||
// A fresh vault read can recover from a failed earlier hydration.
|
||||
}
|
||||
}
|
||||
await connector.connect(this.vault);
|
||||
})();
|
||||
const hydration: ConnectorHydration = { promise, status: 'pending' };
|
||||
this.hydration.set(connector, hydration);
|
||||
void promise.then(
|
||||
() => {
|
||||
if (this.hydration.get(connector) === hydration) hydration.status = 'ready';
|
||||
},
|
||||
() => {
|
||||
if (this.hydration.get(connector) === hydration) hydration.status = 'failed';
|
||||
},
|
||||
);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async waitForHydration(connector: WaggleConnector): Promise<void> {
|
||||
while (true) {
|
||||
const hydration = this.hydration.get(connector);
|
||||
if (!hydration) return;
|
||||
try {
|
||||
await hydration.promise;
|
||||
} catch (err) {
|
||||
if (this.hydration.get(connector) !== hydration) continue;
|
||||
throw err;
|
||||
}
|
||||
if (this.hydration.get(connector) === hydration) return;
|
||||
}
|
||||
}
|
||||
|
||||
private isConnected(connector: WaggleConnector): boolean {
|
||||
try {
|
||||
if (this.connectors.get(connector.id) !== connector) return false;
|
||||
if (this.hydration.get(connector)?.status !== 'ready') return false;
|
||||
if (ALWAYS_CONNECTED_CONNECTOR_IDS.has(connector.id)) return true;
|
||||
const cred = this.vault.getConnectorCredential(connector.id);
|
||||
return Boolean(
|
||||
cred
|
||||
&& !cred.isExpired
|
||||
&& connector.toDefinition('connected').status === 'connected',
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get connectors that have valid (non-expired) credentials in vault OR are mock channel connectors */
|
||||
getConnected(): WaggleConnector[] {
|
||||
// Mock channel connector IDs that are always available without credentials
|
||||
const ALWAYS_CONNECTED = new Set(['slack-mock', 'teams-mock', 'discord-mock']);
|
||||
return [...this.connectors.values()].filter(c => {
|
||||
if (ALWAYS_CONNECTED.has(c.id)) return true;
|
||||
const cred = this.vault.getConnectorCredential(c.id);
|
||||
return cred && !cred.isExpired;
|
||||
});
|
||||
return [...this.connectors.values()].filter(connector => this.isConnected(connector));
|
||||
}
|
||||
|
||||
/** Get ConnectorDefinition[] with live status from vault (for REST API responses) */
|
||||
@@ -61,7 +131,7 @@ export class ConnectorRegistry {
|
||||
const cred = this.vault.getConnectorCredential(c.id);
|
||||
let status: ConnectorDefinition['status'] = 'disconnected';
|
||||
if (cred) {
|
||||
status = cred.isExpired ? 'expired' : 'connected';
|
||||
status = cred.isExpired ? 'expired' : (this.isConnected(c) ? 'connected' : 'disconnected');
|
||||
}
|
||||
return c.toDefinition(status);
|
||||
});
|
||||
@@ -71,13 +141,15 @@ export class ConnectorRegistry {
|
||||
async healthCheck(id: string): Promise<ConnectorHealth | null> {
|
||||
const connector = this.connectors.get(id);
|
||||
if (!connector) return null;
|
||||
await this.waitForHydration(connector);
|
||||
if (this.connectors.get(id) !== connector) return null;
|
||||
return connector.healthCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ToolDefinition[] for all connected connectors.
|
||||
* Each action becomes a tool named `connector_<id>_<action>`.
|
||||
* High-risk actions include _riskLevel metadata for approval gates.
|
||||
* Trusted action risk stays on ToolDefinition metadata for approval gates.
|
||||
*/
|
||||
generateTools(): ToolDefinition[] {
|
||||
const connected = this.getConnected();
|
||||
@@ -89,11 +161,30 @@ export class ConnectorRegistry {
|
||||
tools.push({
|
||||
name: toolName,
|
||||
description: `[${connector.name}] ${action.description}`,
|
||||
riskLevel: action.riskLevel,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
...(action.inputSchema as Record<string, unknown>),
|
||||
},
|
||||
execute: async (args: Record<string, unknown>) => {
|
||||
try {
|
||||
await this.waitForHydration(connector);
|
||||
} catch {
|
||||
const disconnected: ConnectorResult = {
|
||||
success: false,
|
||||
error: 'Connector is not connected',
|
||||
};
|
||||
return JSON.stringify(disconnected);
|
||||
}
|
||||
|
||||
if (!this.isConnected(connector)) {
|
||||
const disconnected: ConnectorResult = {
|
||||
success: false,
|
||||
error: 'Connector is not connected',
|
||||
};
|
||||
return JSON.stringify(disconnected);
|
||||
}
|
||||
|
||||
const cleanArgs = { ...args };
|
||||
|
||||
// Audit log every connector execution
|
||||
|
||||
@@ -92,15 +92,15 @@ export class GoogleCalendarConnector extends BaseConnector {
|
||||
private clientId: string | null = null;
|
||||
private clientSecret: string | null = null;
|
||||
private vault: VaultStore | null = null;
|
||||
private credentialGeneration = 0;
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
this.credentialGeneration += 1;
|
||||
this.vault = vault;
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
if (cred) {
|
||||
this.accessToken = cred.value;
|
||||
this.refreshToken = cred.refreshToken ?? null;
|
||||
this.expiresAt = cred.expiresAt ?? null;
|
||||
}
|
||||
this.accessToken = cred?.value ?? null;
|
||||
this.refreshToken = cred?.refreshToken ?? null;
|
||||
this.expiresAt = cred?.expiresAt ?? null;
|
||||
|
||||
const clientIdEntry = vault.get(`connector:${this.id}:client_id`);
|
||||
this.clientId = clientIdEntry?.value ?? null;
|
||||
@@ -170,13 +170,18 @@ export class GoogleCalendarConnector extends BaseConnector {
|
||||
throw new Error('Cannot refresh token — missing refresh_token, client_id, or client_secret');
|
||||
}
|
||||
|
||||
const credentialGeneration = this.credentialGeneration;
|
||||
const accessToken = this.accessToken;
|
||||
const refreshToken = this.refreshToken;
|
||||
const vault = this.vault;
|
||||
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
refresh_token: this.refreshToken,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
@@ -185,13 +190,23 @@ export class GoogleCalendarConnector extends BaseConnector {
|
||||
if (!res.ok) throw new Error(`Token refresh failed: ${res.status}`);
|
||||
|
||||
const data = await res.json() as { access_token: string; expires_in: number; refresh_token?: string };
|
||||
const currentCredential = vault?.getConnectorCredential(this.id);
|
||||
if (
|
||||
this.credentialGeneration !== credentialGeneration
|
||||
|| !currentCredential
|
||||
|| currentCredential.value !== accessToken
|
||||
|| (currentCredential.refreshToken ?? null) !== refreshToken
|
||||
) {
|
||||
throw new Error('Connector credentials changed during token refresh');
|
||||
}
|
||||
|
||||
this.accessToken = data.access_token;
|
||||
this.expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString();
|
||||
if (data.refresh_token) this.refreshToken = data.refresh_token;
|
||||
|
||||
// Persist updated tokens back to vault
|
||||
if (this.vault) {
|
||||
this.vault.setConnectorCredential(this.id, {
|
||||
if (vault) {
|
||||
vault.setConnectorCredential(this.id, {
|
||||
type: 'oauth2',
|
||||
value: this.accessToken,
|
||||
refreshToken: this.refreshToken ?? undefined,
|
||||
|
||||
@@ -5,9 +5,41 @@
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
import type { ConnectorDefinition, ConnectorHealth, ConnectorStatus } from '@waggle/shared';
|
||||
|
||||
export class JiraConnector extends BaseConnector {
|
||||
static normalizeSiteOrigin(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const candidate = value.trim();
|
||||
const originMatch = /^https:\/\/([^/?#]+)\/?$/i.exec(candidate);
|
||||
if (!originMatch || originMatch[1].includes('@') || originMatch[1].includes(':')) return null;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
const labels = hostname.split('.');
|
||||
if (
|
||||
parsed.protocol !== 'https:'
|
||||
|| parsed.username !== ''
|
||||
|| parsed.password !== ''
|
||||
|| parsed.port !== ''
|
||||
|| parsed.pathname !== '/'
|
||||
|| parsed.search !== ''
|
||||
|| parsed.hash !== ''
|
||||
|| !hostname.endsWith('.atlassian.net')
|
||||
|| labels.some(label => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `https://${hostname}`;
|
||||
}
|
||||
|
||||
readonly id = 'jira';
|
||||
readonly name = 'Jira';
|
||||
readonly description = "Manage Jira issues, projects, and sprints. Search issues with JQL, create and update tickets, transition statuses, and add comments across all Jira projects.";
|
||||
@@ -91,6 +123,13 @@ export class JiraConnector extends BaseConnector {
|
||||
private authHeader: string | null = null;
|
||||
private baseUrl: string | null = null;
|
||||
|
||||
override toDefinition(status: ConnectorStatus): ConnectorDefinition {
|
||||
const effectiveStatus = status === 'connected' && (!this.authHeader || !this.baseUrl)
|
||||
? 'disconnected'
|
||||
: status;
|
||||
return super.toDefinition(effectiveStatus);
|
||||
}
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
if (!cred) {
|
||||
@@ -100,15 +139,16 @@ export class JiraConnector extends BaseConnector {
|
||||
}
|
||||
|
||||
const emailEntry = vault.get(`connector:${this.id}:email`);
|
||||
const email = emailEntry?.value ?? '';
|
||||
const apiToken = cred.value;
|
||||
const email = emailEntry?.value.trim() ?? '';
|
||||
const apiToken = cred.value.trim();
|
||||
|
||||
// Jira Cloud uses email:apiToken as basic auth
|
||||
this.authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`;
|
||||
|
||||
// Base URL from vault or default
|
||||
// Jira Cloud uses email:apiToken as basic auth and only accepts a tenant
|
||||
// origin under *.atlassian.net.
|
||||
const urlEntry = vault.get(`connector:${this.id}:base_url`);
|
||||
this.baseUrl = urlEntry?.value ?? null;
|
||||
this.baseUrl = JiraConnector.normalizeSiteOrigin(urlEntry?.value);
|
||||
this.authHeader = email && apiToken && this.baseUrl
|
||||
? `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`
|
||||
: null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
|
||||
@@ -179,11 +179,20 @@ export class LinearConnector extends BaseConnector {
|
||||
|
||||
private async listIssues(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const first = (params.first as number) ?? 50;
|
||||
const filter: string[] = [];
|
||||
if (params.teamId) filter.push(`team: { id: { eq: "${params.teamId}" } }`);
|
||||
if (params.state) filter.push(`state: { name: { eq: "${params.state}" } }`);
|
||||
const filterClause = filter.length ? `(filter: { ${filter.join(', ')} }, first: ${first})` : `(first: ${first})`;
|
||||
return this.graphql(`{ issues${filterClause} { nodes { id identifier title state { name } priority assignee { name } createdAt } } }`);
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (params.teamId) filter.team = { id: { eq: params.teamId } };
|
||||
if (params.state) filter.state = { name: { eq: params.state } };
|
||||
|
||||
const hasFilter = Object.keys(filter).length > 0;
|
||||
const variables: Record<string, unknown> = { first };
|
||||
if (hasFilter) variables.filter = filter;
|
||||
const filterDefinition = hasFilter ? ', $filter: IssueFilter' : '';
|
||||
const filterArgument = hasFilter ? ', filter: $filter' : '';
|
||||
|
||||
return this.graphql(
|
||||
`query ListIssues($first: Int${filterDefinition}) { issues(first: $first${filterArgument}) { nodes { id identifier title state { name } priority assignee { name } createdAt } } }`,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
|
||||
private async createIssue(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
@@ -227,11 +236,17 @@ export class LinearConnector extends BaseConnector {
|
||||
|
||||
private async listProjects(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const first = (params.first as number) ?? 50;
|
||||
return this.graphql(`{ projects(first: ${first}) { nodes { id name state startDate targetDate } } }`);
|
||||
return this.graphql(
|
||||
`query ListProjects($first: Int) { projects(first: $first) { nodes { id name state startDate targetDate } } }`,
|
||||
{ first },
|
||||
);
|
||||
}
|
||||
|
||||
private async listTeams(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const first = (params.first as number) ?? 50;
|
||||
return this.graphql(`{ teams(first: ${first}) { nodes { id name key description } } }`);
|
||||
return this.graphql(
|
||||
`query ListTeams($first: Int) { teams(first: $first) { nodes { id name key description } } }`,
|
||||
{ first },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
const API_URL = 'https://api.monday.com/v2';
|
||||
const BOARD_KINDS = new Set(['public', 'private', 'share']);
|
||||
|
||||
export class MondayConnector extends BaseConnector {
|
||||
readonly id = 'monday';
|
||||
@@ -167,8 +168,18 @@ export class MondayConnector extends BaseConnector {
|
||||
private async listBoards(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const limit = (params.limit as number) ?? 25;
|
||||
const page = (params.page as number) ?? 1;
|
||||
const kindFilter = params.board_kind ? `, board_kind: ${params.board_kind}` : '';
|
||||
return this.graphql(`{ boards(limit: ${limit}, page: ${page}${kindFilter}) { id name state board_kind columns { id title type } groups { id title } } }`);
|
||||
const boardKind = params.board_kind;
|
||||
if (boardKind !== undefined && (typeof boardKind !== 'string' || !BOARD_KINDS.has(boardKind))) {
|
||||
return { success: false, error: 'Invalid board_kind' };
|
||||
}
|
||||
const kindDefinition = boardKind ? ', $boardKind: BoardKind' : '';
|
||||
const kindFilter = boardKind ? ', board_kind: $boardKind' : '';
|
||||
const variables: Record<string, unknown> = { limit, page };
|
||||
if (boardKind) variables.boardKind = boardKind;
|
||||
return this.graphql(
|
||||
`query ListBoards($limit: Int, $page: Int${kindDefinition}) { boards(limit: $limit, page: $page${kindFilter}) { id name state board_kind columns { id title type } groups { id title } } }`,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
|
||||
private async listItems(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
@@ -176,35 +187,52 @@ export class MondayConnector extends BaseConnector {
|
||||
const boardId = params.boardId;
|
||||
if (params.groupId) {
|
||||
return this.graphql(
|
||||
`{ boards(ids: [${boardId}]) { groups(ids: ["${params.groupId}"]) { items_page(limit: ${limit}) { items { id name column_values { id text value } } } } } }`,
|
||||
`query ListGroupItems($boardId: ID!, $groupId: String!, $limit: Int) { boards(ids: [$boardId]) { groups(ids: [$groupId]) { items_page(limit: $limit) { items { id name column_values { id text value } } } } } }`,
|
||||
{ boardId, groupId: params.groupId, limit },
|
||||
);
|
||||
}
|
||||
return this.graphql(
|
||||
`{ boards(ids: [${boardId}]) { items_page(limit: ${limit}) { items { id name group { id title } column_values { id text value } } } } }`,
|
||||
`query ListItems($boardId: ID!, $limit: Int) { boards(ids: [$boardId]) { items_page(limit: $limit) { items { id name group { id title } column_values { id text value } } } } }`,
|
||||
{ boardId, limit },
|
||||
);
|
||||
}
|
||||
|
||||
private async createItem(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const { boardId, itemName, groupId, columnValues } = params;
|
||||
let mutation = `mutation { create_item(board_id: ${boardId}, item_name: "${String(itemName).replace(/"/g, '\\"')}"`;
|
||||
if (groupId) mutation += `, group_id: "${groupId}"`;
|
||||
if (columnValues) mutation += `, column_values: ${JSON.stringify(String(columnValues))}`;
|
||||
mutation += `) { id name } }`;
|
||||
return this.graphql(mutation);
|
||||
const definitions = ['$boardId: ID!', '$itemName: String!'];
|
||||
const arguments_ = ['board_id: $boardId', 'item_name: $itemName'];
|
||||
const variables: Record<string, unknown> = { boardId, itemName };
|
||||
|
||||
if (groupId) {
|
||||
definitions.push('$groupId: String');
|
||||
arguments_.push('group_id: $groupId');
|
||||
variables.groupId = groupId;
|
||||
}
|
||||
if (columnValues) {
|
||||
definitions.push('$columnValues: JSON');
|
||||
arguments_.push('column_values: $columnValues');
|
||||
variables.columnValues = columnValues;
|
||||
}
|
||||
|
||||
return this.graphql(
|
||||
`mutation CreateItem(${definitions.join(', ')}) { create_item(${arguments_.join(', ')}) { id name } }`,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
|
||||
private async updateItem(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const { boardId, itemId, columnValues } = params;
|
||||
return this.graphql(
|
||||
`mutation { change_multiple_column_values(board_id: ${boardId}, item_id: ${itemId}, column_values: ${JSON.stringify(String(columnValues))}) { id name } }`,
|
||||
`mutation UpdateItem($boardId: ID!, $itemId: ID!, $columnValues: JSON!) { change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id name } }`,
|
||||
{ boardId, itemId, columnValues },
|
||||
);
|
||||
}
|
||||
|
||||
private async searchItems(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
const limit = (params.limit as number) ?? 25;
|
||||
const query = String(params.query).replace(/"/g, '\\"');
|
||||
return this.graphql(
|
||||
`{ items_page_by_column_values(limit: ${limit}, board_id: 0, columns: [{column_id: "name", column_values: ["${query}"]}]) { items { id name board { id name } column_values { id text value } } } }`,
|
||||
`query SearchItems($limit: Int, $query: String!) { items_page_by_column_values(limit: $limit, board_id: 0, columns: [{column_id: "name", column_values: [$query]}]) { items { id name board { id name } column_values { id text value } } } }`,
|
||||
{ limit, query: params.query },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../co
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
|
||||
function isContained(root: string, candidate: string): boolean {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative !== '..'
|
||||
&& !relative.startsWith(`..${path.sep}`)
|
||||
&& !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
export class ObsidianConnector extends BaseConnector {
|
||||
readonly id = 'obsidian';
|
||||
readonly name = 'Obsidian';
|
||||
@@ -143,10 +150,39 @@ export class ObsidianConnector extends BaseConnector {
|
||||
|
||||
/** Resolve a relative path safely within the vault directory */
|
||||
private resolveSafe(relativePath: string): string | null {
|
||||
const resolved = path.resolve(this.vaultPath!, relativePath);
|
||||
// Guard against path traversal
|
||||
if (!resolved.startsWith(this.vaultPath!)) return null;
|
||||
return resolved;
|
||||
if (
|
||||
path.posix.isAbsolute(relativePath)
|
||||
|| path.win32.isAbsolute(relativePath)
|
||||
|| relativePath.split(/[\\/]/).some(part => part.includes(':'))
|
||||
) return null;
|
||||
|
||||
const vaultRoot = path.resolve(this.vaultPath!);
|
||||
const resolved = path.resolve(vaultRoot, relativePath.replace(/[\\/]+/g, path.sep));
|
||||
if (!isContained(vaultRoot, resolved)) return null;
|
||||
|
||||
try {
|
||||
const realVault = fs.realpathSync.native(vaultRoot);
|
||||
let existingAncestor = resolved;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
fs.lstatSync(existingAncestor);
|
||||
break;
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return null;
|
||||
const parent = path.dirname(existingAncestor);
|
||||
if (parent === existingAncestor) return null;
|
||||
existingAncestor = parent;
|
||||
}
|
||||
}
|
||||
|
||||
const realAncestor = fs.realpathSync.native(existingAncestor);
|
||||
if (!isContained(realVault, realAncestor)) return null;
|
||||
return resolved;
|
||||
} catch {
|
||||
// Includes dangling links and races where an ancestor disappears.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively collect all .md files under a directory */
|
||||
|
||||
@@ -5,12 +5,119 @@
|
||||
*/
|
||||
|
||||
import { BaseConnector, type ConnectorAction, type ConnectorResult } from '../connector-sdk.js';
|
||||
import { safeFetch } from '../url-egress-guard.js';
|
||||
import type { VaultStore } from '@waggle/core';
|
||||
import type { ConnectorHealth } from '@waggle/shared';
|
||||
import type { ConnectorDefinition, ConnectorHealth, ConnectorStatus } from '@waggle/shared';
|
||||
|
||||
const API_VERSION = 'v59.0';
|
||||
const MAX_LIST_LIMIT = 2_000;
|
||||
const MAX_SOQL_LENGTH = 20_000;
|
||||
const MAX_FIELD_LIST_LENGTH = 2_048;
|
||||
const MAX_FIELDS = 200;
|
||||
const SALESFORCE_IDENTIFIER = /^[A-Za-z][A-Za-z0-9_]{0,79}$/;
|
||||
|
||||
function requireIdentifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !SALESFORCE_IDENTIFIER.test(value)) {
|
||||
throw new TypeError(`Invalid Salesforce ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireRecordId(value: unknown): string {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9]{15}(?:[A-Za-z0-9]{3})?$/.test(value)) {
|
||||
throw new TypeError('Invalid Salesforce record ID');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireFieldList(value: unknown, defaultFields?: string): string {
|
||||
const candidate = value === undefined ? defaultFields : value;
|
||||
if (typeof candidate !== 'string' || candidate.length === 0 || candidate.length > MAX_FIELD_LIST_LENGTH) {
|
||||
throw new TypeError('Invalid Salesforce field list');
|
||||
}
|
||||
|
||||
const fields = candidate.split(',').map(field => field.trim());
|
||||
if (
|
||||
fields.length === 0
|
||||
|| fields.length > MAX_FIELDS
|
||||
|| fields.some(field => {
|
||||
const segments = field.split('.');
|
||||
return segments.length > 6 || segments.some(segment => !SALESFORCE_IDENTIFIER.test(segment));
|
||||
})
|
||||
) {
|
||||
throw new TypeError('Invalid Salesforce field list');
|
||||
}
|
||||
return fields.join(',');
|
||||
}
|
||||
|
||||
function requireFieldMap(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new TypeError('Invalid Salesforce fields');
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
const fields = value as Record<string, unknown>;
|
||||
const names = Object.keys(fields);
|
||||
if (
|
||||
(prototype !== Object.prototype && prototype !== null)
|
||||
|| names.length === 0
|
||||
|| names.length > MAX_FIELDS
|
||||
|| names.some(name => !SALESFORCE_IDENTIFIER.test(name))
|
||||
) {
|
||||
throw new TypeError('Invalid Salesforce fields');
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function requireListLimit(value: unknown): number {
|
||||
const limit = value === undefined ? 25 : value;
|
||||
if (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIST_LIMIT) {
|
||||
throw new TypeError(`Salesforce limit must be an integer from 1 to ${MAX_LIST_LIMIT}`);
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
function requireSoqlQuery(value: unknown): string {
|
||||
if (typeof value !== 'string') throw new TypeError('Invalid Salesforce SOQL query');
|
||||
const query = value.trim();
|
||||
if (query.length === 0 || query.length > MAX_SOQL_LENGTH) {
|
||||
throw new TypeError('Invalid Salesforce SOQL query');
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
export class SalesforceConnector extends BaseConnector {
|
||||
static normalizeInstanceOrigin(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const candidate = value.trim();
|
||||
const originMatch = /^https:\/\/([^/?#]+)\/?$/i.exec(candidate);
|
||||
if (!originMatch || originMatch[1].includes('@') || originMatch[1].includes(':')) return null;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
const labels = hostname.split('.');
|
||||
if (
|
||||
parsed.protocol !== 'https:'
|
||||
|| parsed.username !== ''
|
||||
|| parsed.password !== ''
|
||||
|| parsed.port !== ''
|
||||
|| parsed.pathname !== '/'
|
||||
|| parsed.search !== ''
|
||||
|| parsed.hash !== ''
|
||||
|| !hostname.endsWith('.salesforce.com')
|
||||
|| labels.some(label => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `https://${hostname}`;
|
||||
}
|
||||
|
||||
readonly id = 'salesforce';
|
||||
readonly name = 'Salesforce';
|
||||
readonly description = "Query and manage Salesforce objects using SOQL. Access leads, contacts, opportunities, accounts, and custom objects with full CRM visibility.";
|
||||
@@ -31,14 +138,15 @@ export class SalesforceConnector extends BaseConnector {
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
riskLevel: 'low',
|
||||
// Arbitrary SOQL can expose any object/field visible to the credential.
|
||||
riskLevel: 'high',
|
||||
},
|
||||
{
|
||||
name: 'list_contacts',
|
||||
description: 'List contacts with optional limit',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
limit: { type: 'number', description: 'Max results (default 25)' },
|
||||
limit: { type: 'number', description: `Max results (default 25, max ${MAX_LIST_LIMIT})` },
|
||||
fields: { type: 'string', description: 'Comma-separated field names (default: Id,Name,Email,Phone)' },
|
||||
},
|
||||
},
|
||||
@@ -50,7 +158,7 @@ export class SalesforceConnector extends BaseConnector {
|
||||
inputSchema: {
|
||||
properties: {
|
||||
objectType: { type: 'string', description: 'Salesforce object type (e.g., "Contact", "Account", "Lead")' },
|
||||
recordId: { type: 'string', description: 'Salesforce record ID (18-char)' },
|
||||
recordId: { type: 'string', description: 'Salesforce record ID (15 or 18 characters)' },
|
||||
fields: { type: 'string', description: 'Comma-separated field names to retrieve' },
|
||||
},
|
||||
required: ['objectType', 'recordId'],
|
||||
@@ -87,7 +195,7 @@ export class SalesforceConnector extends BaseConnector {
|
||||
description: 'List opportunities with optional limit',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
limit: { type: 'number', description: 'Max results (default 25)' },
|
||||
limit: { type: 'number', description: `Max results (default 25, max ${MAX_LIST_LIMIT})` },
|
||||
fields: { type: 'string', description: 'Comma-separated field names (default: Id,Name,StageName,Amount,CloseDate)' },
|
||||
},
|
||||
},
|
||||
@@ -98,13 +206,20 @@ export class SalesforceConnector extends BaseConnector {
|
||||
private token: string | null = null;
|
||||
private instanceUrl: string | null = null;
|
||||
|
||||
override toDefinition(status: ConnectorStatus): ConnectorDefinition {
|
||||
const effectiveStatus = status === 'connected' && (!this.token || !this.instanceUrl)
|
||||
? 'disconnected'
|
||||
: status;
|
||||
return super.toDefinition(effectiveStatus);
|
||||
}
|
||||
|
||||
async connect(vault: VaultStore): Promise<void> {
|
||||
const cred = vault.getConnectorCredential(this.id);
|
||||
this.token = cred?.value ?? null;
|
||||
|
||||
// Instance URL from vault metadata (e.g., "https://mycompany.salesforce.com")
|
||||
const urlEntry = vault.get(`connector:${this.id}:instance_url`);
|
||||
this.instanceUrl = urlEntry?.value ?? null;
|
||||
this.instanceUrl = SalesforceConnector.normalizeInstanceOrigin(urlEntry?.value);
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
@@ -117,10 +232,10 @@ export class SalesforceConnector extends BaseConnector {
|
||||
|
||||
if (this.token && this.instanceUrl) {
|
||||
try {
|
||||
const res = await fetch(`${this.instanceUrl}/services/data/${API_VERSION}/limits`, {
|
||||
const res = await safeFetch(`${this.instanceUrl}/services/data/${API_VERSION}/limits`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
}, { maxRedirects: 0 });
|
||||
if (!res.ok) {
|
||||
health.status = 'error';
|
||||
health.error = `Salesforce API returned ${res.status}`;
|
||||
@@ -163,11 +278,11 @@ export class SalesforceConnector extends BaseConnector {
|
||||
|
||||
private async soqlQuery(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const query = encodeURIComponent(String(params.query));
|
||||
const res = await fetch(`${this.apiBase}/query?q=${query}`, {
|
||||
const query = encodeURIComponent(requireSoqlQuery(params.query));
|
||||
const res = await safeFetch(`${this.apiBase}/query?q=${query}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
}, { maxRedirects: 0 });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
@@ -177,13 +292,14 @@ export class SalesforceConnector extends BaseConnector {
|
||||
|
||||
private async listObjects(objectType: string, params: Record<string, unknown>, defaultFields: string): Promise<ConnectorResult> {
|
||||
try {
|
||||
const limit = (params.limit as number) ?? 25;
|
||||
const fields = (params.fields as string) ?? defaultFields;
|
||||
const soql = `SELECT ${fields} FROM ${objectType} ORDER BY CreatedDate DESC LIMIT ${limit}`;
|
||||
const res = await fetch(`${this.apiBase}/query?q=${encodeURIComponent(soql)}`, {
|
||||
const limit = requireListLimit(params.limit);
|
||||
const fields = requireFieldList(params.fields, defaultFields);
|
||||
const safeObjectType = requireIdentifier(objectType, 'object type');
|
||||
const soql = `SELECT ${fields} FROM ${safeObjectType} ORDER BY CreatedDate DESC LIMIT ${limit}`;
|
||||
const res = await safeFetch(`${this.apiBase}/query?q=${encodeURIComponent(soql)}`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
}, { maxRedirects: 0 });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
@@ -193,14 +309,14 @@ export class SalesforceConnector extends BaseConnector {
|
||||
|
||||
private async getRecord(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const objectType = String(params.objectType);
|
||||
const recordId = String(params.recordId);
|
||||
let url = `${this.apiBase}/sobjects/${objectType}/${recordId}`;
|
||||
if (params.fields) url += `?fields=${encodeURIComponent(String(params.fields))}`;
|
||||
const res = await fetch(url, {
|
||||
const objectType = requireIdentifier(params.objectType, 'object type');
|
||||
const recordId = requireRecordId(params.recordId);
|
||||
let url = `${this.apiBase}/sobjects/${encodeURIComponent(objectType)}/${encodeURIComponent(recordId)}`;
|
||||
if (params.fields !== undefined) url += `?fields=${encodeURIComponent(requireFieldList(params.fields))}`;
|
||||
const res = await safeFetch(url, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
}, { maxRedirects: 0 });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
@@ -210,14 +326,14 @@ export class SalesforceConnector extends BaseConnector {
|
||||
|
||||
private async createRecord(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const objectType = String(params.objectType);
|
||||
const fields = params.fields as Record<string, unknown>;
|
||||
const res = await fetch(`${this.apiBase}/sobjects/${objectType}`, {
|
||||
const objectType = requireIdentifier(params.objectType, 'object type');
|
||||
const fields = requireFieldMap(params.fields);
|
||||
const res = await safeFetch(`${this.apiBase}/sobjects/${encodeURIComponent(objectType)}`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(fields),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
}, { maxRedirects: 0 });
|
||||
if (!res.ok) return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };
|
||||
return { success: true, data: await res.json() };
|
||||
} catch (err: unknown) {
|
||||
@@ -227,15 +343,15 @@ export class SalesforceConnector extends BaseConnector {
|
||||
|
||||
private async updateRecord(params: Record<string, unknown>): Promise<ConnectorResult> {
|
||||
try {
|
||||
const objectType = String(params.objectType);
|
||||
const recordId = String(params.recordId);
|
||||
const fields = params.fields as Record<string, unknown>;
|
||||
const res = await fetch(`${this.apiBase}/sobjects/${objectType}/${recordId}`, {
|
||||
const objectType = requireIdentifier(params.objectType, 'object type');
|
||||
const recordId = requireRecordId(params.recordId);
|
||||
const fields = requireFieldMap(params.fields);
|
||||
const res = await safeFetch(`${this.apiBase}/sobjects/${encodeURIComponent(objectType)}/${encodeURIComponent(recordId)}`, {
|
||||
method: 'PATCH',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(fields),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
}, { maxRedirects: 0 });
|
||||
// Salesforce returns 204 No Content on successful update
|
||||
if (res.status !== 204 && !res.ok) {
|
||||
return { success: false, error: await this.safeErrorText(res, 'Salesforce API') };
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { COMPACTION_PROMPT } from './behavioral-spec.js';
|
||||
import { createCoreLogger } from '@waggle/core';
|
||||
import { createCoreLogger, evaluateExternalMemoryIngress } from '@waggle/core';
|
||||
const log = createCoreLogger('context-compressor');
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
@@ -59,6 +59,17 @@ export interface CompressibleMessage {
|
||||
content: string;
|
||||
}
|
||||
|
||||
function previousSummarySystemMessage(previousSummary: string): string {
|
||||
return `You are summarizing a conversation that has been compressed before. Here is the previous summary:\n\n${previousSummary}\n\nNow incorporate the new messages below into an updated summary.`;
|
||||
}
|
||||
|
||||
function safePreviousSummary(previousSummary?: string | null): string | null {
|
||||
if (previousSummary === undefined || previousSummary === null) return null;
|
||||
return evaluateExternalMemoryIngress({ content: previousSummarySystemMessage(previousSummary) }).action === 'allow'
|
||||
? previousSummary
|
||||
: null;
|
||||
}
|
||||
|
||||
// ── Step 1: Token Estimation ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -238,6 +249,7 @@ export async function summarizeMiddle(
|
||||
config: Pick<CompressionConfig, 'budgetModel' | 'litellmUrl' | 'litellmApiKey' | 'fetch'>,
|
||||
previousSummary?: string | null,
|
||||
): Promise<string> {
|
||||
previousSummary = safePreviousSummary(previousSummary);
|
||||
if (middle.length === 0) return previousSummary ?? '';
|
||||
|
||||
const fetchFn = config.fetch ?? globalThis.fetch;
|
||||
@@ -249,7 +261,7 @@ export async function summarizeMiddle(
|
||||
if (previousSummary) {
|
||||
summarizerMessages.push({
|
||||
role: 'system',
|
||||
content: `You are summarizing a conversation that has been compressed before. Here is the previous summary:\n\n${previousSummary}\n\nNow incorporate the new messages below into an updated summary.`,
|
||||
content: previousSummarySystemMessage(previousSummary),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -268,14 +280,21 @@ export async function summarizeMiddle(
|
||||
temperature: 0.1,
|
||||
};
|
||||
|
||||
const response = await fetchFn(`${config.litellmUrl}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.litellmApiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchFn(`${config.litellmUrl}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.litellmApiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
} catch (error) {
|
||||
log.warn(`Summarizer request failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return buildFallbackSummary(middle, previousSummary);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
@@ -285,11 +304,39 @@ export async function summarizeMiddle(
|
||||
return buildFallbackSummary(middle, previousSummary);
|
||||
}
|
||||
|
||||
const result = await response.json() as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
let result: {
|
||||
choices?: Array<{
|
||||
finish_reason?: string | null;
|
||||
message?: {
|
||||
content?: string | null;
|
||||
tool_calls?: unknown[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
const content = result.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
try {
|
||||
const parsed = await response.json() as unknown;
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
log.warn('Summarizer response was not a JSON object; using deterministic fallback');
|
||||
return buildFallbackSummary(middle, previousSummary);
|
||||
}
|
||||
result = parsed as typeof result;
|
||||
} catch (error) {
|
||||
log.warn(`Summarizer response was not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return buildFallbackSummary(middle, previousSummary);
|
||||
}
|
||||
const choice = result.choices?.[0];
|
||||
if (choice?.finish_reason !== 'stop') {
|
||||
log.warn(
|
||||
`Summarizer returned an incomplete completion (finish_reason=${choice?.finish_reason ?? 'missing'}); using deterministic fallback`,
|
||||
);
|
||||
return buildFallbackSummary(middle, previousSummary);
|
||||
}
|
||||
if (choice.message?.tool_calls?.length) {
|
||||
log.warn('Summarizer returned tool calls with finish_reason=stop; using deterministic fallback');
|
||||
return buildFallbackSummary(middle, previousSummary);
|
||||
}
|
||||
const content = choice.message?.content;
|
||||
if (typeof content !== 'string' || content.trim().length === 0) {
|
||||
return buildFallbackSummary(middle, previousSummary);
|
||||
}
|
||||
|
||||
@@ -337,6 +384,7 @@ export async function compressConversation(
|
||||
previousSummary?: string | null,
|
||||
): Promise<CompressionResult> {
|
||||
const originalTokens = estimateTokens(messages);
|
||||
const safeSummary = safePreviousSummary(previousSummary);
|
||||
|
||||
// Step 1: Detect — do we need compression?
|
||||
if (!needsCompression(messages, config)) {
|
||||
@@ -346,7 +394,7 @@ export async function compressConversation(
|
||||
originalTokens,
|
||||
compressedTokens: originalTokens,
|
||||
summaryGenerated: false,
|
||||
summary: previousSummary ?? null,
|
||||
summary: safeSummary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -368,12 +416,12 @@ export async function compressConversation(
|
||||
originalTokens,
|
||||
compressedTokens: estimateTokens(result),
|
||||
summaryGenerated: false,
|
||||
summary: previousSummary ?? null,
|
||||
summary: safeSummary,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 4: Summarize the middle
|
||||
const summary = await summarizeMiddle(regions.middle, config, previousSummary);
|
||||
const summary = await summarizeMiddle(regions.middle, config, safeSummary);
|
||||
|
||||
// Step 5: Inject — replace middle with a single summary message
|
||||
const summaryMessage: CompressibleMessage = {
|
||||
@@ -381,6 +429,18 @@ export async function compressConversation(
|
||||
content: `[Conversation compressed — ${regions.middle.length} messages summarized]\n\n${summary}`,
|
||||
};
|
||||
|
||||
if (evaluateExternalMemoryIngress({ content: summaryMessage.content }).action !== 'allow') {
|
||||
const messagesWithoutSummary = [...regions.head, ...regions.tail];
|
||||
return {
|
||||
messages: messagesWithoutSummary,
|
||||
compressed: true,
|
||||
originalTokens,
|
||||
compressedTokens: estimateTokens(messagesWithoutSummary),
|
||||
summaryGenerated: false,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
const compressed = [...regions.head, summaryMessage, ...regions.tail];
|
||||
const compressedTokens = estimateTokens(compressed);
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface UsageEntry {
|
||||
output: number;
|
||||
timestamp: string;
|
||||
workspaceId?: string;
|
||||
billingClass?: ModelSpendBillingClass;
|
||||
fixedCostUsd?: number;
|
||||
}
|
||||
|
||||
export interface UsageStats {
|
||||
@@ -36,6 +38,13 @@ export const DEFAULT_MODEL_PRICING: Record<string, ModelPricing> = {
|
||||
'claude-haiku-4-5-20251001': { inputPer1k: 0.001, outputPer1k: 0.005 },
|
||||
'claude-haiku-3-5': { inputPer1k: 0.00025, outputPer1k: 0.00125 },
|
||||
'claude-3-5-haiku-20241022': { inputPer1k: 0.00025, outputPer1k: 0.00125 },
|
||||
// Gemini 2.5 Flash standard text rates ($0.30/$2.50 per 1M)
|
||||
'gemini-2.5-flash': { inputPer1k: 0.0003, outputPer1k: 0.0025 },
|
||||
'google/gemini-2.5-flash': { inputPer1k: 0.0003, outputPer1k: 0.0025 },
|
||||
// GPT-5.3-Codex standard text rates ($1.75/$14 per 1M)
|
||||
'gpt-5.3-codex': { inputPer1k: 0.00175, outputPer1k: 0.014 },
|
||||
'openai/gpt-5.3-codex': { inputPer1k: 0.00175, outputPer1k: 0.014 },
|
||||
'openrouter/openai/gpt-5.3-codex': { inputPer1k: 0.00175, outputPer1k: 0.014 },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -43,12 +52,17 @@ export const DEFAULT_MODEL_PRICING: Record<string, ModelPricing> = {
|
||||
* the Anthropic tier word in the id so an unrecognized Opus snapshot isn't
|
||||
* costed at ~5× under Sonnet rates. Defaults to Sonnet for everything else.
|
||||
*/
|
||||
function fallbackPricingFor(model: string): { label: string; pricing: ModelPricing } {
|
||||
function fallbackPricingFor(
|
||||
model: string,
|
||||
inferOllamaFree = true,
|
||||
): { label: string; pricing: ModelPricing } {
|
||||
const m = model.toLowerCase();
|
||||
// Ollama runs on the user's machine and does not incur provider charges.
|
||||
// Treat unknown local model tags as explicitly free instead of inventing a
|
||||
// cloud-model estimate or emitting a misleading warning.
|
||||
if (m.startsWith('ollama/')) return { label: 'Local (free)', pricing: { inputPer1k: 0, outputPer1k: 0 } };
|
||||
if (inferOllamaFree && m.startsWith('ollama/')) {
|
||||
return { label: 'Local (free)', pricing: { inputPer1k: 0, outputPer1k: 0 } };
|
||||
}
|
||||
if (m.includes('opus')) return { label: 'Opus', pricing: { inputPer1k: 0.015, outputPer1k: 0.075 } };
|
||||
if (m.includes('haiku')) return { label: 'Haiku', pricing: { inputPer1k: 0.001, outputPer1k: 0.005 } };
|
||||
return { label: 'Sonnet', pricing: { inputPer1k: 0.003, outputPer1k: 0.015 } };
|
||||
@@ -58,8 +72,82 @@ function fallbackPricingFor(model: string): { label: string; pricing: ModelPrici
|
||||
const warnedUnknownModels = new Set<string>();
|
||||
|
||||
export type BudgetMode = 'soft' | 'hard';
|
||||
export type ModelSpendBillingClass = 'priced' | 'free';
|
||||
|
||||
export interface ModelSpendReservationRequest {
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
maxOutputTokens: number;
|
||||
workspaceId?: string;
|
||||
/** Set only after the server has verified an offline/free provider route. */
|
||||
billingClass?: ModelSpendBillingClass;
|
||||
}
|
||||
|
||||
export interface ModelSpendReservation {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export const MODEL_SPEND_RESERVATION_HEADER = 'x-waggle-model-spend-reservation';
|
||||
|
||||
export interface ModelSpendReservationHandoff {
|
||||
reservation: ModelSpendReservation;
|
||||
estimatedCostUsd: number;
|
||||
durableTraceId?: number;
|
||||
}
|
||||
|
||||
export type ModelSpendReservationDisposition = 'commit' | 'release';
|
||||
|
||||
function isCanonicalModelSpendReservationTarget(targetUrl: string): boolean {
|
||||
const match = /^http:\/\/127\.0\.0\.1:([1-9]\d{0,4})\/v1$/.exec(targetUrl);
|
||||
if (!match) return false;
|
||||
const port = Number(match[1]);
|
||||
return Number.isInteger(port) && port <= 65_535;
|
||||
}
|
||||
|
||||
export interface ModelSpendBudget {
|
||||
reserveModelSpend(request: ModelSpendReservationRequest): ModelSpendReservation;
|
||||
reconcileModelSpend(
|
||||
reservation: ModelSpendReservation,
|
||||
usage: { inputTokens: number; outputTokens: number },
|
||||
): boolean;
|
||||
commitReservedModelSpend(reservation: ModelSpendReservation): boolean;
|
||||
releaseReservedModelSpend(reservation: ModelSpendReservation): boolean;
|
||||
issueModelSpendReservationHandoff?(
|
||||
reservation: ModelSpendReservation,
|
||||
requestBinding: string,
|
||||
targetUrl: string,
|
||||
durableTraceId?: number,
|
||||
): { token: string } | undefined;
|
||||
claimModelSpendReservationHandoff?(
|
||||
token: string,
|
||||
requestBinding: string,
|
||||
): ModelSpendReservationHandoff | undefined;
|
||||
discardModelSpendReservationHandoff?(token: string): void;
|
||||
setModelSpendReservationHandoffDisposition?(
|
||||
token: string,
|
||||
disposition: ModelSpendReservationDisposition,
|
||||
): void;
|
||||
takeModelSpendReservationHandoffDisposition?(
|
||||
token: string,
|
||||
): ModelSpendReservationDisposition | undefined;
|
||||
registerModelSpendReservationTarget?(targetUrl: string): boolean;
|
||||
unregisterModelSpendReservationTarget?(targetUrl: string): void;
|
||||
markModelSpendPersistenceUnavailable?(cause: unknown): void;
|
||||
}
|
||||
|
||||
interface StoredModelSpendReservation extends ModelSpendReservation {
|
||||
day: string;
|
||||
createdAt: string;
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
maxOutputTokens: number;
|
||||
workspaceId?: string;
|
||||
billingClass: ModelSpendBillingClass;
|
||||
estimatedCostUsd: number;
|
||||
}
|
||||
|
||||
export class BudgetExceededError extends Error {
|
||||
public readonly code = 'DAILY_MODEL_BUDGET_EXCEEDED';
|
||||
public readonly budgetUsd: number;
|
||||
public readonly currentUsd: number;
|
||||
constructor(budgetUsd: number, currentUsd: number) {
|
||||
@@ -70,18 +158,51 @@ export class BudgetExceededError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class CostTracker {
|
||||
export class BudgetPricingUnavailableError extends Error {
|
||||
public readonly code = 'DAILY_MODEL_BUDGET_PRICING_UNAVAILABLE';
|
||||
constructor(model: string) {
|
||||
super(`Hard daily budget cannot price model "${model}" from the trusted catalog`);
|
||||
this.name = 'BudgetPricingUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BudgetPersistenceUnavailableError extends Error {
|
||||
public readonly code = 'DAILY_MODEL_BUDGET_LEDGER_UNAVAILABLE';
|
||||
|
||||
constructor(cause?: unknown) {
|
||||
super('Hard daily model budget cannot continue without a durable spend ledger', { cause });
|
||||
this.name = 'BudgetPersistenceUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CostTracker implements ModelSpendBudget {
|
||||
private pricing: Record<string, ModelPricing>;
|
||||
private usage: UsageEntry[] = [];
|
||||
private dailyCarryover: { day: string; costUsd: number } | null = null;
|
||||
private dailyBudgetUsd: number | null = null;
|
||||
private budgetMode: BudgetMode = 'soft';
|
||||
private reservations = new Map<string, StoredModelSpendReservation>();
|
||||
private reservationHandoffs = new Map<string, {
|
||||
reservationId: string;
|
||||
requestBinding: string;
|
||||
targetUrl: string;
|
||||
durableTraceId?: number;
|
||||
state: 'issued' | 'claimed';
|
||||
}>();
|
||||
private reservationHandoffDispositions = new Map<string, ModelSpendReservationDisposition>();
|
||||
private modelSpendReservationTargets = new Set<string>();
|
||||
private modelSpendPersistenceFailure: unknown;
|
||||
private nextReservationId = 0;
|
||||
|
||||
constructor(pricing: Record<string, ModelPricing> = {}) {
|
||||
this.pricing = { ...DEFAULT_MODEL_PRICING, ...pricing };
|
||||
}
|
||||
|
||||
setBudget(dailyUsd: number | null, mode: BudgetMode = 'soft'): void {
|
||||
this.dailyBudgetUsd = dailyUsd;
|
||||
if (dailyUsd !== null && (!Number.isFinite(dailyUsd) || dailyUsd < 0)) {
|
||||
throw new RangeError('Daily budget must be a non-negative finite number or null');
|
||||
}
|
||||
this.dailyBudgetUsd = dailyUsd === 0 ? null : dailyUsd;
|
||||
this.budgetMode = mode;
|
||||
}
|
||||
|
||||
@@ -95,7 +216,7 @@ export class CostTracker {
|
||||
*/
|
||||
checkBudget(): boolean {
|
||||
if (this.dailyBudgetUsd === null) return true;
|
||||
const current = this.getDailyTotal();
|
||||
const current = this.getDailyTotal() + this.getReservedDailyTotal();
|
||||
if (current >= this.dailyBudgetUsd) {
|
||||
if (this.budgetMode === 'hard') {
|
||||
throw new BudgetExceededError(this.dailyBudgetUsd, current);
|
||||
@@ -106,6 +227,7 @@ export class CostTracker {
|
||||
}
|
||||
|
||||
addUsage(model: string, inputTokens: number, outputTokens: number, workspaceId?: string): void {
|
||||
this.assertValidTokens(inputTokens, outputTokens);
|
||||
this.usage.push({
|
||||
model,
|
||||
input: inputTokens,
|
||||
@@ -115,6 +237,208 @@ export class CostTracker {
|
||||
});
|
||||
}
|
||||
|
||||
/** Reserve conservative provider spend before any network dispatch. */
|
||||
reserveModelSpend(request: ModelSpendReservationRequest): ModelSpendReservation {
|
||||
if (!Number.isFinite(request.inputTokens) || request.inputTokens < 0
|
||||
|| !Number.isFinite(request.maxOutputTokens) || request.maxOutputTokens < 0) {
|
||||
throw new RangeError('Model spend token estimates must be non-negative finite numbers');
|
||||
}
|
||||
|
||||
if (
|
||||
this.budgetMode === 'hard'
|
||||
&& this.dailyBudgetUsd !== null
|
||||
&& this.modelSpendPersistenceFailure !== undefined
|
||||
) {
|
||||
throw new BudgetPersistenceUnavailableError(this.modelSpendPersistenceFailure);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const day = now.slice(0, 10);
|
||||
const billingClass = request.billingClass ?? 'priced';
|
||||
if (
|
||||
billingClass === 'priced'
|
||||
&& this.budgetMode === 'hard'
|
||||
&& this.dailyBudgetUsd !== null
|
||||
&& this.resolveTrustedPricing(request.model) === undefined
|
||||
) {
|
||||
throw new BudgetPricingUnavailableError(request.model);
|
||||
}
|
||||
const estimatedCostUsd = billingClass === 'free'
|
||||
? 0
|
||||
: this.roundUpUsd(this.calculateCostWithPolicy(
|
||||
request.inputTokens,
|
||||
request.maxOutputTokens,
|
||||
request.model,
|
||||
false,
|
||||
));
|
||||
const committed = this.getDailyTotal();
|
||||
const reserved = this.getReservedDailyTotal(day);
|
||||
|
||||
if (
|
||||
this.budgetMode === 'hard'
|
||||
&& this.dailyBudgetUsd !== null
|
||||
&& estimatedCostUsd > 0
|
||||
&& committed + reserved + estimatedCostUsd > this.dailyBudgetUsd
|
||||
) {
|
||||
throw new BudgetExceededError(this.dailyBudgetUsd, committed + reserved);
|
||||
}
|
||||
|
||||
const id = `${day}:${++this.nextReservationId}`;
|
||||
this.reservations.set(id, {
|
||||
id,
|
||||
day,
|
||||
createdAt: now,
|
||||
model: request.model,
|
||||
inputTokens: request.inputTokens,
|
||||
maxOutputTokens: request.maxOutputTokens,
|
||||
workspaceId: request.workspaceId,
|
||||
billingClass,
|
||||
estimatedCostUsd,
|
||||
});
|
||||
return { id };
|
||||
}
|
||||
|
||||
issueModelSpendReservationHandoff(
|
||||
reservation: ModelSpendReservation,
|
||||
requestBinding: string,
|
||||
targetUrl: string,
|
||||
durableTraceId?: number,
|
||||
): { token: string } | undefined {
|
||||
if (!this.reservations.has(reservation.id) || !this.modelSpendReservationTargets.has(targetUrl)) {
|
||||
return undefined;
|
||||
}
|
||||
if (durableTraceId !== undefined && (!Number.isSafeInteger(durableTraceId) || durableTraceId <= 0)) {
|
||||
return undefined;
|
||||
}
|
||||
for (const handoff of this.reservationHandoffs.values()) {
|
||||
if (handoff.reservationId === reservation.id) return undefined;
|
||||
}
|
||||
const token = crypto.randomUUID();
|
||||
this.reservationHandoffs.set(token, {
|
||||
reservationId: reservation.id,
|
||||
requestBinding,
|
||||
targetUrl,
|
||||
durableTraceId,
|
||||
state: 'issued',
|
||||
});
|
||||
return { token };
|
||||
}
|
||||
|
||||
claimModelSpendReservationHandoff(
|
||||
token: string,
|
||||
requestBinding: string,
|
||||
): ModelSpendReservationHandoff | undefined {
|
||||
const handoff = this.reservationHandoffs.get(token);
|
||||
if (!handoff || handoff.state !== 'issued') return undefined;
|
||||
const stored = this.reservations.get(handoff.reservationId);
|
||||
if (!stored) {
|
||||
this.reservationHandoffs.delete(token);
|
||||
return undefined;
|
||||
}
|
||||
if (handoff.requestBinding !== requestBinding) {
|
||||
this.reservationHandoffs.delete(token);
|
||||
this.reservationHandoffDispositions.delete(token);
|
||||
return undefined;
|
||||
}
|
||||
handoff.state = 'claimed';
|
||||
return {
|
||||
reservation: { id: stored.id },
|
||||
estimatedCostUsd: stored.estimatedCostUsd,
|
||||
...(handoff.durableTraceId === undefined ? {} : { durableTraceId: handoff.durableTraceId }),
|
||||
};
|
||||
}
|
||||
|
||||
discardModelSpendReservationHandoff(token: string): void {
|
||||
this.reservationHandoffs.delete(token);
|
||||
this.reservationHandoffDispositions.delete(token);
|
||||
}
|
||||
|
||||
setModelSpendReservationHandoffDisposition(
|
||||
token: string,
|
||||
disposition: ModelSpendReservationDisposition,
|
||||
): void {
|
||||
if (this.reservationHandoffs.get(token)?.state !== 'claimed') return;
|
||||
this.reservationHandoffDispositions.set(token, disposition);
|
||||
}
|
||||
|
||||
takeModelSpendReservationHandoffDisposition(
|
||||
token: string,
|
||||
): ModelSpendReservationDisposition | undefined {
|
||||
const disposition = this.reservationHandoffDispositions.get(token);
|
||||
this.reservationHandoffDispositions.delete(token);
|
||||
return disposition;
|
||||
}
|
||||
|
||||
registerModelSpendReservationTarget(targetUrl: string): boolean {
|
||||
if (!isCanonicalModelSpendReservationTarget(targetUrl)) return false;
|
||||
this.modelSpendReservationTargets.add(targetUrl);
|
||||
return true;
|
||||
}
|
||||
|
||||
unregisterModelSpendReservationTarget(targetUrl: string): void {
|
||||
this.modelSpendReservationTargets.delete(targetUrl);
|
||||
for (const [token, handoff] of this.reservationHandoffs) {
|
||||
if (handoff.targetUrl === targetUrl) {
|
||||
this.reservationHandoffs.delete(token);
|
||||
this.reservationHandoffDispositions.delete(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markModelSpendPersistenceUnavailable(cause: unknown): void {
|
||||
this.modelSpendPersistenceFailure = cause;
|
||||
}
|
||||
|
||||
/** Replace a reservation with authoritative provider usage, exactly once. */
|
||||
reconcileModelSpend(
|
||||
reservation: ModelSpendReservation,
|
||||
usage: { inputTokens: number; outputTokens: number },
|
||||
): boolean {
|
||||
if (!this.hasValidTokens(usage.inputTokens, usage.outputTokens)) {
|
||||
return this.commitReservedModelSpend(reservation);
|
||||
}
|
||||
const stored = this.takeReservation(reservation);
|
||||
if (!stored) return false;
|
||||
this.usage.push({
|
||||
model: stored.model,
|
||||
input: Math.max(0, usage.inputTokens),
|
||||
output: Math.max(0, usage.outputTokens),
|
||||
timestamp: stored.createdAt,
|
||||
workspaceId: stored.workspaceId,
|
||||
billingClass: stored.billingClass,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Conservatively charge the estimate after an ambiguous dispatched failure. */
|
||||
commitReservedModelSpend(reservation: ModelSpendReservation): boolean {
|
||||
const stored = this.takeReservation(reservation);
|
||||
if (!stored) return false;
|
||||
this.usage.push({
|
||||
model: stored.model,
|
||||
input: stored.inputTokens,
|
||||
output: stored.maxOutputTokens,
|
||||
timestamp: stored.createdAt,
|
||||
workspaceId: stored.workspaceId,
|
||||
billingClass: stored.billingClass,
|
||||
fixedCostUsd: stored.estimatedCostUsd,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Release only on a definite pre-inference provider rejection. */
|
||||
releaseReservedModelSpend(reservation: ModelSpendReservation): boolean {
|
||||
return Boolean(this.takeReservation(reservation));
|
||||
}
|
||||
|
||||
getReservedDailyTotal(day = new Date().toISOString().slice(0, 10)): number {
|
||||
let total = 0;
|
||||
for (const reservation of this.reservations.values()) {
|
||||
if (reservation.day === day) total += reservation.estimatedCostUsd;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Get raw usage entries (for cost routes). */
|
||||
getUsageEntries(): ReadonlyArray<UsageEntry> {
|
||||
return this.usage;
|
||||
@@ -122,15 +446,24 @@ export class CostTracker {
|
||||
|
||||
/** Calculate cost for a single usage entry. */
|
||||
calculateCost(input: number, output: number, model: string): number {
|
||||
const price = this.pricing[model];
|
||||
return this.calculateCostWithPolicy(input, output, model, true);
|
||||
}
|
||||
|
||||
private calculateCostWithPolicy(
|
||||
input: number,
|
||||
output: number,
|
||||
model: string,
|
||||
inferOllamaFree: boolean,
|
||||
): number {
|
||||
const price = this.resolveTrustedPricing(model);
|
||||
if (price) {
|
||||
return (input / 1000) * price.inputPer1k + (output / 1000) * price.outputPer1k;
|
||||
}
|
||||
// Unknown model: fall back to family-aware pricing (not always Sonnet — an
|
||||
// unrecognized Opus id would otherwise under-report ~5×) and warn loudly
|
||||
// once so the cost isn't silently wrong.
|
||||
const { label, pricing } = fallbackPricingFor(model);
|
||||
if (model.toLowerCase().startsWith('ollama/')) {
|
||||
const { label, pricing } = fallbackPricingFor(model, inferOllamaFree);
|
||||
if (inferOllamaFree && model.toLowerCase().startsWith('ollama/')) {
|
||||
return (input / 1000) * pricing.inputPer1k + (output / 1000) * pricing.outputPer1k;
|
||||
}
|
||||
if (!warnedUnknownModels.has(model)) {
|
||||
@@ -151,7 +484,7 @@ export class CostTracker {
|
||||
for (const u of this.usage) {
|
||||
totalInput += u.input;
|
||||
totalOutput += u.output;
|
||||
const cost = this.calculateCost(u.input, u.output, u.model);
|
||||
const cost = this.usageCost(u);
|
||||
totalCost += cost;
|
||||
if (!byModel[u.model]) byModel[u.model] = { input: 0, output: 0, cost: 0 };
|
||||
byModel[u.model].input += u.input;
|
||||
@@ -167,19 +500,93 @@ export class CostTracker {
|
||||
let total = 0;
|
||||
for (const u of this.usage) {
|
||||
if (u.workspaceId === workspaceId) {
|
||||
total += this.calculateCost(u.input, u.output, u.model);
|
||||
total += this.usageCost(u);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Get total estimated cost for the current session (proxy for daily total). */
|
||||
hasDailyCarryover(day: string): boolean {
|
||||
return this.dailyCarryover?.day === day;
|
||||
}
|
||||
|
||||
/** Resolve provider-wrapped IDs only when their suffix exists in the trusted catalog. */
|
||||
private resolveTrustedPricing(model: string): ModelPricing | undefined {
|
||||
let candidate = model;
|
||||
while (candidate.length > 0) {
|
||||
const price = this.pricing[candidate];
|
||||
if (price) return price;
|
||||
const separator = candidate.indexOf('/');
|
||||
if (separator < 0) return undefined;
|
||||
candidate = candidate.slice(separator + 1);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Seed cost persisted before this process started, once per UTC day. */
|
||||
initializeDailyCarryover(day: string, costUsd: number): void {
|
||||
if (this.hasDailyCarryover(day)) return;
|
||||
this.dailyCarryover = {
|
||||
day,
|
||||
costUsd: Number.isFinite(costUsd) ? Math.max(0, costUsd) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Get today's persisted carryover plus in-process usage (UTC calendar day). */
|
||||
getDailyTotal(): number {
|
||||
return this.getStats().estimatedCost;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
let total = this.dailyCarryover?.day === today
|
||||
? this.dailyCarryover.costUsd
|
||||
: 0;
|
||||
for (const entry of this.usage) {
|
||||
if (entry.timestamp.startsWith(today)) {
|
||||
total += this.usageCost(entry);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
formatSummary(): string {
|
||||
const stats = this.getStats();
|
||||
return `Tokens: ${stats.totalInputTokens} in / ${stats.totalOutputTokens} out (${stats.turns} turns) | Est. cost: $${stats.estimatedCost.toFixed(4)}`;
|
||||
}
|
||||
|
||||
private takeReservation(
|
||||
reservation: ModelSpendReservation,
|
||||
): StoredModelSpendReservation | undefined {
|
||||
const stored = this.reservations.get(reservation.id);
|
||||
if (!stored) return undefined;
|
||||
this.reservations.delete(reservation.id);
|
||||
for (const [token, handoff] of this.reservationHandoffs) {
|
||||
if (handoff.reservationId === reservation.id) {
|
||||
this.reservationHandoffs.delete(token);
|
||||
this.reservationHandoffDispositions.delete(token);
|
||||
}
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
private usageCost(entry: UsageEntry): number {
|
||||
if (entry.fixedCostUsd !== undefined) return entry.fixedCostUsd;
|
||||
if (entry.billingClass === 'free') return 0;
|
||||
if (entry.billingClass === 'priced') {
|
||||
return this.calculateCostWithPolicy(entry.input, entry.output, entry.model, false);
|
||||
}
|
||||
return this.calculateCost(entry.input, entry.output, entry.model);
|
||||
}
|
||||
|
||||
private hasValidTokens(inputTokens: number, outputTokens: number): boolean {
|
||||
return Number.isFinite(inputTokens) && inputTokens >= 0
|
||||
&& Number.isFinite(outputTokens) && outputTokens >= 0;
|
||||
}
|
||||
|
||||
private assertValidTokens(inputTokens: number, outputTokens: number): void {
|
||||
if (!this.hasValidTokens(inputTokens, outputTokens)) {
|
||||
throw new RangeError('Model usage tokens must be non-negative finite numbers');
|
||||
}
|
||||
}
|
||||
|
||||
private roundUpUsd(value: number): number {
|
||||
return Math.ceil((Math.max(0, value) * 1_000_000) - 1e-9) / 1_000_000;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,28 @@ import path from 'node:path';
|
||||
import type { AgentPersona } from './personas.js';
|
||||
|
||||
const PERSONAS_DIR = 'personas';
|
||||
const INVALID_PORTABLE_ID_CHARACTERS = /[<>:"/\\|?*]/;
|
||||
const WINDOWS_RESERVED_BASENAME = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])$/i;
|
||||
|
||||
/** True when an ID is safe as one portable Windows/macOS filename segment. */
|
||||
export function isValidCustomPersonaId(id: string): boolean {
|
||||
if (id.length === 0 || id.length > 200 || id === '.' || id === '..') return false;
|
||||
const hasControlCharacter = [...id].some((character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
return codePoint <= 0x1f || codePoint === 0x7f;
|
||||
});
|
||||
if (hasControlCharacter || INVALID_PORTABLE_ID_CHARACTERS.test(id) || /[ .]$/.test(id)) return false;
|
||||
|
||||
// Windows reserves device basenames even when an extension is present.
|
||||
const basename = (id.split('.')[0] ?? id).trimEnd();
|
||||
return !WINDOWS_RESERVED_BASENAME.test(basename);
|
||||
}
|
||||
|
||||
export function assertValidCustomPersonaId(id: string): void {
|
||||
if (!isValidCustomPersonaId(id)) {
|
||||
throw new Error('Invalid custom persona ID');
|
||||
}
|
||||
}
|
||||
|
||||
export function loadCustomPersonas(dataDir: string): AgentPersona[] {
|
||||
const dir = path.join(dataDir, PERSONAS_DIR);
|
||||
@@ -30,6 +52,7 @@ export function loadCustomPersonas(dataDir: string): AgentPersona[] {
|
||||
}
|
||||
|
||||
export function saveCustomPersona(dataDir: string, persona: AgentPersona): void {
|
||||
assertValidCustomPersonaId(persona.id);
|
||||
const dir = path.join(dataDir, PERSONAS_DIR);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const filePath = path.join(dir, `${persona.id}.json`);
|
||||
@@ -37,6 +60,7 @@ export function saveCustomPersona(dataDir: string, persona: AgentPersona): void
|
||||
}
|
||||
|
||||
export function deleteCustomPersona(dataDir: string, id: string): boolean {
|
||||
assertValidCustomPersonaId(id);
|
||||
const filePath = path.join(dataDir, PERSONAS_DIR, `${id}.json`);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
|
||||
@@ -32,8 +32,10 @@ import type { ToolDefinition } from './tools.js';
|
||||
* Resolve a relative path within a workspace, rejecting traversal outside it.
|
||||
*/
|
||||
function resolveSafe(workspace: string, filePath: string): string {
|
||||
const resolved = path.resolve(workspace, filePath);
|
||||
if (!resolved.startsWith(path.resolve(workspace))) {
|
||||
const root = path.resolve(workspace);
|
||||
const resolved = path.resolve(root, filePath);
|
||||
const relative = path.relative(root, resolved);
|
||||
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error(`Path resolves outside workspace: ${filePath}`);
|
||||
}
|
||||
return resolved;
|
||||
|
||||
@@ -32,6 +32,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { AgentPersona } from './personas.js';
|
||||
import { getPersona } from './personas.js';
|
||||
import { assertValidCustomPersonaId } from './custom-personas.js';
|
||||
|
||||
// ── Public result shape ────────────────────────────────────────
|
||||
|
||||
@@ -52,7 +53,7 @@ export interface DeployPersonaInput {
|
||||
/** The evolved system prompt text */
|
||||
systemPrompt: string;
|
||||
/** Optional additional field overrides */
|
||||
overrides?: Partial<AgentPersona>;
|
||||
overrides?: Partial<Omit<AgentPersona, 'id' | 'systemPrompt'>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +69,7 @@ export function deployPersonaOverride(
|
||||
dataDir: string,
|
||||
input: DeployPersonaInput,
|
||||
): DeployResult {
|
||||
assertValidCustomPersonaId(input.personaId);
|
||||
const personasDir = path.join(dataDir, 'personas');
|
||||
if (!fs.existsSync(personasDir)) {
|
||||
fs.mkdirSync(personasDir, { recursive: true });
|
||||
@@ -81,19 +83,24 @@ export function deployPersonaOverride(
|
||||
|
||||
const builtin = getPersona(input.personaId);
|
||||
const persona: AgentPersona = builtin
|
||||
? { ...builtin, ...input.overrides, systemPrompt: input.systemPrompt }
|
||||
: {
|
||||
? {
|
||||
...builtin,
|
||||
...input.overrides,
|
||||
id: input.personaId,
|
||||
systemPrompt: input.systemPrompt,
|
||||
}
|
||||
: {
|
||||
name: input.personaId,
|
||||
description: input.overrides?.description ?? `${input.personaId} (evolved)`,
|
||||
icon: input.overrides?.icon ?? 'sparkles',
|
||||
systemPrompt: input.systemPrompt,
|
||||
modelPreference: input.overrides?.modelPreference ?? 'claude-sonnet-4-6',
|
||||
tools: input.overrides?.tools ?? [],
|
||||
workspaceAffinity: input.overrides?.workspaceAffinity ?? [],
|
||||
suggestedCommands: input.overrides?.suggestedCommands ?? [],
|
||||
defaultWorkflow: input.overrides?.defaultWorkflow ?? null,
|
||||
...input.overrides,
|
||||
id: input.personaId,
|
||||
systemPrompt: input.systemPrompt,
|
||||
};
|
||||
|
||||
writeAtomic(filePath, JSON.stringify(persona, null, 2));
|
||||
@@ -113,6 +120,7 @@ export function rollbackPersonaOverride(
|
||||
dataDir: string,
|
||||
personaId: string,
|
||||
): boolean {
|
||||
assertValidCustomPersonaId(personaId);
|
||||
const filePath = path.join(dataDir, 'personas', `${personaId}.json`);
|
||||
const backupPath = `${filePath}.bak`;
|
||||
if (fs.existsSync(backupPath)) {
|
||||
@@ -270,5 +278,22 @@ export function applyBehavioralSpecOverrides(
|
||||
function writeAtomic(filePath: string, contents: string): void {
|
||||
const tmpPath = `${filePath}.tmp`;
|
||||
fs.writeFileSync(tmpPath, contents, 'utf-8');
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
const waitBuffer = new Int32Array(new SharedArrayBuffer(4));
|
||||
|
||||
try {
|
||||
for (let attempt = 1; attempt <= 10; attempt++) {
|
||||
try {
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
const transient = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
|
||||
if (!transient || attempt === 10) throw error;
|
||||
// Windows antivirus and indexers can briefly hold an exclusive handle.
|
||||
Atomics.wait(waitBuffer, 0, 0, 25 * attempt);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { fs.rmSync(tmpPath, { force: true }); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
70
packages/agent/src/external-process-env.ts
Normal file
70
packages/agent/src/external-process-env.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { mergePathValue, resolvedShellPath } from './shell-env.js';
|
||||
|
||||
/**
|
||||
* Non-secret host context required to start CLI and desktop processes.
|
||||
* Unknown variables are omitted so newly added provider or infrastructure
|
||||
* credentials cannot silently cross the external-process boundary.
|
||||
*/
|
||||
const BASE_ENV_ALLOWLIST = new Set([
|
||||
'PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'SYSTEMDRIVE', 'COMSPEC',
|
||||
'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'USER', 'USERNAME',
|
||||
'LOGNAME', 'SHELL',
|
||||
'APPDATA', 'LOCALAPPDATA', 'HERMES_HOME', 'PROGRAMDATA', 'PROGRAMFILES',
|
||||
'PROGRAMFILES(X86)', 'PROGRAMW6432',
|
||||
'TEMP', 'TMP', 'TMPDIR',
|
||||
'LANG', 'LANGUAGE', 'LC_ALL', 'LC_ADDRESS', 'LC_COLLATE', 'LC_CTYPE',
|
||||
'LC_IDENTIFICATION', 'LC_MEASUREMENT', 'LC_MESSAGES', 'LC_MONETARY',
|
||||
'LC_NAME', 'LC_NUMERIC', 'LC_PAPER', 'LC_TELEPHONE', 'LC_TIME',
|
||||
'TERM', 'COLORTERM',
|
||||
'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'TZ',
|
||||
'OS', 'PROCESSOR_ARCHITECTURE', 'PROCESSOR_IDENTIFIER',
|
||||
'NUMBER_OF_PROCESSORS',
|
||||
'DISPLAY', 'WAYLAND_DISPLAY', 'XAUTHORITY', 'DBUS_SESSION_BUS_ADDRESS',
|
||||
'XDG_RUNTIME_DIR', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME',
|
||||
'XDG_STATE_HOME', 'XDG_SESSION_TYPE', 'XDG_CURRENT_DESKTOP',
|
||||
'DESKTOP_SESSION', '__CF_USER_TEXT_ENCODING',
|
||||
]);
|
||||
|
||||
/** Explicit Waggle runtime metadata constructed by trusted launch code. */
|
||||
const WAGGLE_ENV_ALLOWLIST = new Set([
|
||||
'WAGGLE_WORKSPACE_ID', 'WAGGLE_WORKSPACE_PATH',
|
||||
'WAGGLE_RUN_ID', 'WAGGLE_ROOM_ID', 'WAGGLE_SENDER_ID',
|
||||
'WAGGLE_DANCE_TEAM_ID', 'WAGGLE_DANCE_URL', 'WAGGLE_RUN_TOKEN',
|
||||
'WAGGLE_CLI_NODE_PATH', 'WAGGLE_CLI_ENTRY',
|
||||
'WAGGLE_SIGNAL_EMIT', 'WAGGLE_SIDECAR_URL',
|
||||
'WAGGLE_HOOK_NODE_PATH', 'HIVE_MIND_DATA_DIR', 'NO_COLOR',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Build a fail-closed environment for user-installed AI tools and hook bins.
|
||||
* Provider keys, credential helpers, proxy credentials, infrastructure
|
||||
* secrets, and arbitrary ambient variables are never inherited. The caller
|
||||
* may add only the narrow Waggle metadata enumerated above.
|
||||
*/
|
||||
export function buildExternalProcessEnv(
|
||||
base: NodeJS.ProcessEnv,
|
||||
waggleEnv: NodeJS.ProcessEnv = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const [key, value] of Object.entries(base)) {
|
||||
const upper = key.toUpperCase();
|
||||
if (value !== undefined && BASE_ENV_ALLOWLIST.has(upper)) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// GUI-launched POSIX sidecars can inherit a bare PATH. Preserve the existing
|
||||
// login-shell recovery without importing any other shell variables.
|
||||
if (platform !== 'win32') {
|
||||
const shellPath = resolvedShellPath();
|
||||
if (shellPath) env.PATH = mergePathValue(shellPath, env.PATH);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(waggleEnv)) {
|
||||
if (value !== undefined && WAGGLE_ENV_ALLOWLIST.has(key.toUpperCase())) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -10,7 +10,8 @@ import type {
|
||||
} from '@waggle/shared';
|
||||
import { resolveToolCommandInvocation } from './tool-command.js';
|
||||
import { stripAnsi } from './tool-output-buffer.js';
|
||||
import { resolvedShellPath, mergePathValue } from './shell-env.js';
|
||||
import { buildExternalProcessEnv } from './external-process-env.js';
|
||||
import { spawnSidecarOwnedProcess } from './sidecar-owned-process.js';
|
||||
|
||||
const MAX_STDOUT = 256 * 1024;
|
||||
const MAX_STDERR = 64 * 1024;
|
||||
@@ -19,13 +20,8 @@ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1_000;
|
||||
const MAX_TIMEOUT_MS = 30 * 60 * 1_000;
|
||||
const DEFAULT_STALL_AFTER_MS = 120_000;
|
||||
const MIN_STALL_AFTER_MS = 30_000;
|
||||
|
||||
const ENV_ALLOWLIST = new Set([
|
||||
'PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'HOME', 'USERPROFILE',
|
||||
'APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP', 'LANG', 'LC_ALL', 'TERM',
|
||||
'SSH_AUTH_SOCK', 'GIT_ASKPASS', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY',
|
||||
'OPENROUTER_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_API_KEY', 'XAI_API_KEY',
|
||||
]);
|
||||
const TREE_KILL_TIMEOUT_MS = 5_000;
|
||||
const TERMINATION_SETTLE_MS = 2_000;
|
||||
|
||||
export type ExternalRunEventType =
|
||||
| 'started' | 'progress' | 'message' | 'tool'
|
||||
@@ -72,6 +68,7 @@ export interface ExternalToolRunResult {
|
||||
status: 'completed' | 'failed' | 'cancelled' | 'timed_out';
|
||||
exitCode: number | null;
|
||||
summary: string;
|
||||
error?: string;
|
||||
sessionId?: string;
|
||||
stdoutTail: string;
|
||||
stderrTail: string;
|
||||
@@ -80,9 +77,12 @@ export interface ExternalToolRunResult {
|
||||
|
||||
export interface ExternalProcessHandle {
|
||||
pid: number;
|
||||
exitCode: number | null;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
stdout: { on(event: 'data', cb: (chunk: Buffer | string) => void): void };
|
||||
stderr: { on(event: 'data', cb: (chunk: Buffer | string) => void): void };
|
||||
stdin: { write(value: string): void; end(): void };
|
||||
kill(signal?: NodeJS.Signals | number): boolean;
|
||||
once(event: 'error', cb: (error: Error) => void): void;
|
||||
once(event: 'exit', cb: (code: number | null) => void): void;
|
||||
}
|
||||
@@ -128,6 +128,12 @@ export async function runExternalTool(
|
||||
const promptFile = task.promptTransport === 'temp-file'
|
||||
? (deps.createPromptFile ?? defaultCreatePromptFile)(request.prompt)
|
||||
: undefined;
|
||||
let promptCleaned = false;
|
||||
const cleanupPromptFile = () => {
|
||||
if (promptCleaned) return;
|
||||
promptCleaned = true;
|
||||
try { promptFile?.cleanup(); } catch { /* best-effort secure temp cleanup */ }
|
||||
};
|
||||
const args = renderArgs(
|
||||
request.sessionId && task.resumeArgvTemplate ? task.resumeArgvTemplate : task.argvTemplate,
|
||||
task,
|
||||
@@ -136,7 +142,7 @@ export async function runExternalTool(
|
||||
promptFile?.path,
|
||||
timeoutMs,
|
||||
);
|
||||
const env = buildExternalToolEnv(deps.baseEnv ?? process.env, request, workspacePath);
|
||||
const env = buildExternalToolEnv(deps.baseEnv ?? process.env, request, workspacePath, platform);
|
||||
const spawnProcess = deps.spawnProcess ?? defaultSpawnProcess;
|
||||
const killTree = deps.killTree ?? defaultKillTree;
|
||||
const parseState: ParseState = { finalText: '' };
|
||||
@@ -146,7 +152,6 @@ export async function runExternalTool(
|
||||
let seq = 0;
|
||||
let abortRequested = request.signal?.aborted ?? false;
|
||||
let timedOut = false;
|
||||
let killRequested = false;
|
||||
let lastEventAtMs = startedAt;
|
||||
let stalledEpisode = false;
|
||||
|
||||
@@ -169,7 +174,7 @@ export async function runExternalTool(
|
||||
};
|
||||
|
||||
if (abortRequested) {
|
||||
promptFile?.cleanup();
|
||||
cleanupPromptFile();
|
||||
emit('cancelled', 'Cancelled before launch');
|
||||
return terminalResult('cancelled', null, '', '', '', now() - startedAt);
|
||||
}
|
||||
@@ -178,29 +183,13 @@ export async function runExternalTool(
|
||||
try {
|
||||
child = spawnProcess(request.binary, args, { cwd: workspacePath, env });
|
||||
} catch (err) {
|
||||
promptFile?.cleanup();
|
||||
cleanupPromptFile();
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
emit('failed', message);
|
||||
return terminalResult('failed', null, message, '', message, now() - startedAt);
|
||||
}
|
||||
emit('started', `Started ${request.manifest.displayName}`, child.pid);
|
||||
|
||||
const requestKill = async () => {
|
||||
if (killRequested) return;
|
||||
killRequested = true;
|
||||
try { await killTree(child.pid, platform); }
|
||||
catch (err) { stderr = appendTail(stderr, err instanceof Error ? err.message : String(err), MAX_STDERR); }
|
||||
};
|
||||
|
||||
const abortHandler = () => {
|
||||
abortRequested = true;
|
||||
void requestKill();
|
||||
};
|
||||
request.signal?.addEventListener('abort', abortHandler, { once: true });
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
void requestKill();
|
||||
}, timeoutMs);
|
||||
const stallTimer = stallAfterMs === undefined ? undefined : setInterval(() => {
|
||||
if (abortRequested || timedOut) return;
|
||||
const idleMs = now() - lastEventAtMs;
|
||||
@@ -236,15 +225,18 @@ export async function runExternalTool(
|
||||
const progress = stripAnsi(text).trim();
|
||||
if (progress) emit('progress', progress);
|
||||
});
|
||||
if (task.promptTransport === 'stdin') child.stdin.write(request.prompt);
|
||||
child.stdin.end();
|
||||
|
||||
return await new Promise<ExternalToolRunResult>((resolve) => {
|
||||
let settled = false;
|
||||
let terminationIntent: 'cancelled' | 'timed_out' | null = null;
|
||||
let treeKillDeadline: NodeJS.Timeout | undefined;
|
||||
let settlementTimer: NodeJS.Timeout | undefined;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
const finish = (exitCode: number | null, spawnError?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (treeKillDeadline) clearTimeout(treeKillDeadline);
|
||||
if (settlementTimer) clearTimeout(settlementTimer);
|
||||
if (stallTimer) clearInterval(stallTimer);
|
||||
request.signal?.removeEventListener('abort', abortHandler);
|
||||
if (stdoutRemainder.trim()) parseLine(task.outputDialect, stdoutRemainder, parseState, emit);
|
||||
@@ -254,30 +246,123 @@ export async function runExternalTool(
|
||||
if (task.outputDialect === 'hermes-text') {
|
||||
parseState.sessionId = extractSessionId(stripAnsi(stderr)) ?? parseState.sessionId;
|
||||
}
|
||||
promptFile?.cleanup();
|
||||
cleanupPromptFile();
|
||||
|
||||
if (
|
||||
task.outputDialect === 'claude-stream-json' &&
|
||||
exitCode === 0 &&
|
||||
!timedOut &&
|
||||
!abortRequested &&
|
||||
!spawnError &&
|
||||
!parseState.finalText &&
|
||||
!parseState.error
|
||||
) {
|
||||
parseState.error = 'Claude Code completed without a final response';
|
||||
}
|
||||
|
||||
const cleanStdout = redact(stdout, env);
|
||||
const cleanStderr = redact(stderr, env);
|
||||
const summary = truncate(
|
||||
stripAnsi(
|
||||
redact(parseState.finalText, env) ||
|
||||
(cleanStdout.trim() || cleanStderr.trim() || spawnError?.message || ''),
|
||||
).trim(),
|
||||
MAX_STDOUT,
|
||||
);
|
||||
let status: ExternalToolRunResult['status'];
|
||||
if (timedOut) status = 'timed_out';
|
||||
else if (abortRequested) status = 'cancelled';
|
||||
else if (spawnError || exitCode !== 0 || parseState.error) status = 'failed';
|
||||
else status = 'completed';
|
||||
emit(status, status === 'completed' ? summary : (parseState.error || spawnError?.message || cleanStderr || summary));
|
||||
const terminalError = status === 'failed'
|
||||
? truncate(stripAnsi(
|
||||
(parseState.error ? redact(parseState.error, env) : '') ||
|
||||
(spawnError ? redact(spawnError.message, env) : '') ||
|
||||
cleanStderr.trim() ||
|
||||
(exitCode !== null && exitCode !== 0
|
||||
? `${request.manifest.displayName} exited with code ${exitCode}`
|
||||
: ''),
|
||||
).trim(), MAX_STDERR)
|
||||
: undefined;
|
||||
const stdoutFallback = task.outputDialect === 'claude-stream-json' ? '' : cleanStdout.trim();
|
||||
const summary = truncate(
|
||||
stripAnsi(
|
||||
redact(parseState.finalText, env) ||
|
||||
stdoutFallback ||
|
||||
cleanStderr.trim() ||
|
||||
terminalError ||
|
||||
'',
|
||||
).trim(),
|
||||
MAX_STDOUT,
|
||||
);
|
||||
emit(status, status === 'completed' ? summary : (terminalError || summary));
|
||||
resolve({
|
||||
...terminalResult(status, exitCode, summary, cleanStdout, cleanStderr, now() - startedAt),
|
||||
...(terminalError ? { error: terminalError } : {}),
|
||||
...(parseState.sessionId ? { sessionId: parseState.sessionId } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const appendCleanupError = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
stderr = appendTail(stderr, message, MAX_STDERR);
|
||||
};
|
||||
const childHasExited = () => child.exitCode !== null || child.signalCode !== null;
|
||||
const forceRootKill = () => {
|
||||
if (settled || childHasExited()) return;
|
||||
try {
|
||||
if (!child.kill('SIGKILL')) appendCleanupError('Root process refused SIGKILL');
|
||||
} catch (error) {
|
||||
appendCleanupError(error);
|
||||
}
|
||||
};
|
||||
const scheduleForcedSettlement = () => {
|
||||
if (settled || settlementTimer) return;
|
||||
settlementTimer = setTimeout(() => finish(child.exitCode), TERMINATION_SETTLE_MS);
|
||||
};
|
||||
const requestTermination = (intent: 'cancelled' | 'timed_out') => {
|
||||
if (settled || terminationIntent) return;
|
||||
if (childHasExited()) {
|
||||
finish(child.exitCode);
|
||||
return;
|
||||
}
|
||||
terminationIntent = intent;
|
||||
abortRequested = intent === 'cancelled';
|
||||
timedOut = intent === 'timed_out';
|
||||
if (intent === 'cancelled' && timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
|
||||
let treeAttemptFinished = false;
|
||||
const finishTreeAttempt = (fallbackToRoot: boolean, error?: unknown) => {
|
||||
if (settled || treeAttemptFinished) return;
|
||||
treeAttemptFinished = true;
|
||||
if (treeKillDeadline) {
|
||||
clearTimeout(treeKillDeadline);
|
||||
treeKillDeadline = undefined;
|
||||
}
|
||||
if (error !== undefined) appendCleanupError(error);
|
||||
if (fallbackToRoot) forceRootKill();
|
||||
scheduleForcedSettlement();
|
||||
};
|
||||
|
||||
treeKillDeadline = setTimeout(() => {
|
||||
finishTreeAttempt(true, new Error(`Process-tree cleanup exceeded ${TREE_KILL_TIMEOUT_MS}ms`));
|
||||
}, TREE_KILL_TIMEOUT_MS);
|
||||
try {
|
||||
void killTree(child.pid, platform).then(
|
||||
() => finishTreeAttempt(false),
|
||||
(error) => finishTreeAttempt(true, error),
|
||||
);
|
||||
} catch (error) {
|
||||
finishTreeAttempt(true, error);
|
||||
}
|
||||
};
|
||||
const abortHandler = () => requestTermination('cancelled');
|
||||
|
||||
child.once('error', (error) => finish(null, error));
|
||||
child.once('exit', (code) => finish(code));
|
||||
timeout = setTimeout(() => requestTermination('timed_out'), timeoutMs);
|
||||
request.signal?.addEventListener('abort', abortHandler, { once: true });
|
||||
// Close the spawn/listener race: an abort can land after the pre-launch
|
||||
// check but before the listener above is attached.
|
||||
if (request.signal?.aborted) abortHandler();
|
||||
if (!abortRequested && task.promptTransport === 'stdin') child.stdin.write(request.prompt);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -285,20 +370,9 @@ export function buildExternalToolEnv(
|
||||
base: NodeJS.ProcessEnv,
|
||||
request: Pick<ExternalToolRunRequest, 'runId' | 'roomId' | 'workspaceId' | 'dance' | 'dataDir'>,
|
||||
workspacePath: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const [key, value] of Object.entries(base)) {
|
||||
if (value !== undefined && ENV_ALLOWLIST.has(key.toUpperCase())) env[key] = value;
|
||||
}
|
||||
// POSIX GUI-launched sidecars inherit a bare PATH. Merge the
|
||||
// resolved login-shell PATH so spawned CLIs resolve their shims; the value
|
||||
// stays ENV_ALLOWLIST-scoped (PATH only). No-op on win32 / before resolve.
|
||||
if (process.platform !== 'win32') {
|
||||
const shellPath = resolvedShellPath();
|
||||
if (shellPath) env.PATH = mergePathValue(shellPath, env.PATH);
|
||||
}
|
||||
return {
|
||||
...env,
|
||||
return buildExternalProcessEnv(base, {
|
||||
WAGGLE_RUN_ID: request.runId,
|
||||
WAGGLE_ROOM_ID: request.roomId,
|
||||
WAGGLE_WORKSPACE_ID: request.workspaceId,
|
||||
@@ -314,7 +388,7 @@ export function buildExternalToolEnv(
|
||||
...(request.dataDir ? { HIVE_MIND_DATA_DIR: request.dataDir } : {}),
|
||||
WAGGLE_SIGNAL_EMIT: '0',
|
||||
NO_COLOR: '1',
|
||||
};
|
||||
}, platform);
|
||||
}
|
||||
|
||||
function requireTaskSpec(
|
||||
@@ -425,17 +499,26 @@ function parseJsonValue(
|
||||
|
||||
if (dialect === 'claude-stream-json') {
|
||||
if (type === 'result') {
|
||||
state.finalText = stringValue(record.result) ?? state.finalText;
|
||||
if (record.is_error === true) state.error = state.finalText || 'Claude Code reported an error';
|
||||
const result = stringValue(record.result);
|
||||
const subtype = stringValue(record.subtype);
|
||||
if (result) state.finalText = result;
|
||||
if (record.is_error === true || subtype?.startsWith('error_')) {
|
||||
state.error = stringValue(record.error) || result || subtype || 'Claude Code reported an error';
|
||||
}
|
||||
return;
|
||||
}
|
||||
const blocks = ((record.message as Record<string, unknown> | undefined)?.content ?? record.content) as unknown;
|
||||
const assistantText: string[] = [];
|
||||
for (const block of Array.isArray(blocks) ? blocks : []) {
|
||||
if (!block || typeof block !== 'object') continue;
|
||||
const item = block as Record<string, unknown>;
|
||||
if (item.type === 'text' && typeof item.text === 'string') emit('message', item.text);
|
||||
if (item.type === 'text' && typeof item.text === 'string') {
|
||||
assistantText.push(item.text);
|
||||
emit('message', item.text);
|
||||
}
|
||||
if (item.type === 'tool_use') emit('tool', String(item.name ?? 'tool'));
|
||||
}
|
||||
if (assistantText.length > 0) state.finalText = assistantText.join('\n');
|
||||
return;
|
||||
}
|
||||
if (dialect === 'codex-jsonl') {
|
||||
@@ -541,21 +624,34 @@ function defaultSpawnProcess(
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv },
|
||||
): ExternalProcessHandle {
|
||||
const invocation = resolveToolCommandInvocation(binary, args);
|
||||
const child = spawn(invocation.binary, invocation.args, {
|
||||
const child = spawnSidecarOwnedProcess(invocation.binary, invocation.args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
shell: false,
|
||||
detached: process.platform !== 'win32',
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
return child as unknown as ExternalProcessHandle;
|
||||
}
|
||||
|
||||
export function resolveWindowsTaskkillPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const candidate = env.SystemRoot ?? env.WINDIR;
|
||||
const windowsRoot = candidate && path.win32.isAbsolute(candidate)
|
||||
? path.win32.normalize(candidate)
|
||||
: 'C:\\Windows';
|
||||
return path.win32.join(windowsRoot, 'System32', 'taskkill.exe');
|
||||
}
|
||||
|
||||
async function defaultKillTree(pid: number, platform: NodeJS.Platform): Promise<void> {
|
||||
if (platform === 'win32') {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile('taskkill.exe', ['/PID', String(pid), '/T', '/F'], (error) => error ? reject(error) : resolve());
|
||||
execFile(
|
||||
resolveWindowsTaskkillPath(),
|
||||
['/PID', String(pid), '/T', '/F'],
|
||||
{ timeout: TREE_KILL_TIMEOUT_MS, windowsHide: true },
|
||||
(error) => error ? reject(error) : resolve(),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { ToolDefinition } from './tools.js';
|
||||
import { buildExternalProcessEnv } from './external-process-env.js';
|
||||
|
||||
const NO_WORKSPACE_REPO = 'Error: No Git repository exists inside the active workspace.';
|
||||
const EXECUTABLE_FILTER_CONFIG = /^filter\.(.+)\.(?:clean|process)$/i;
|
||||
const READ_ONLY_GIT_CONFIG = ['-c', 'core.fsmonitor=false', '-c', 'pager.diff=false'];
|
||||
|
||||
function readOnlyGitArgs(...args: string[]): string[] {
|
||||
return [...READ_ONLY_GIT_CONFIG, ...args];
|
||||
}
|
||||
|
||||
/** Extract a human-readable message from a child_process spawn error. */
|
||||
function spawnErrorText(err: unknown): string {
|
||||
@@ -8,18 +19,129 @@ function spawnErrorText(err: unknown): string {
|
||||
return stderrText?.trim() || (err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
function runGit(cwd: string, args: string[], timeoutMs = 10_000): string {
|
||||
function gitEnvironment(workspaceRoot: string): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_WORK_TREE;
|
||||
delete env.GIT_COMMON_DIR;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
delete env.GIT_OBJECT_DIRECTORY;
|
||||
delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
|
||||
env.GIT_CEILING_DIRECTORIES = path.dirname(workspaceRoot);
|
||||
return env;
|
||||
}
|
||||
|
||||
function gitDiffEnvironment(workspaceRoot: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...buildExternalProcessEnv(process.env),
|
||||
GIT_CEILING_DIRECTORIES: path.dirname(workspaceRoot),
|
||||
GIT_NO_LAZY_FETCH: '1',
|
||||
GIT_OPTIONAL_LOCKS: '0',
|
||||
};
|
||||
}
|
||||
|
||||
function configuredExecutableFilters(
|
||||
repoRoot: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string[] {
|
||||
let configNames: string;
|
||||
try {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf-8', timeout: timeoutMs }).trim();
|
||||
configNames = execFileSync(
|
||||
'git',
|
||||
readOnlyGitArgs('config', '--name-only', '--get-regexp', '^filter\\..*\\.(clean|process)$'),
|
||||
{ cwd: repoRoot, env, encoding: 'utf-8', timeout: 10_000 },
|
||||
);
|
||||
} catch (err) {
|
||||
if ((err as { status?: number }).status === 1) return [];
|
||||
throw err;
|
||||
}
|
||||
|
||||
const drivers = new Set<string>();
|
||||
for (const name of configNames.split(/\r?\n/)) {
|
||||
const driver = name.match(EXECUTABLE_FILTER_CONFIG)?.[1];
|
||||
if (!driver) continue;
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(driver)) {
|
||||
throw new Error(`unsafe Git filter driver name: ${JSON.stringify(driver)}`);
|
||||
}
|
||||
drivers.add(driver);
|
||||
}
|
||||
return [...drivers];
|
||||
}
|
||||
|
||||
export function buildReadOnlyGitDiffArgs(
|
||||
repoRoot: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
options: { staged?: boolean; file?: string },
|
||||
): string[] {
|
||||
const filterOverrides = configuredExecutableFilters(repoRoot, env).flatMap((driver) => [
|
||||
'-c', `filter.${driver}.clean=`,
|
||||
'-c', `filter.${driver}.process=`,
|
||||
'-c', `filter.${driver}.required=false`,
|
||||
]);
|
||||
const args = readOnlyGitArgs(
|
||||
...filterOverrides,
|
||||
'diff', '--no-ext-diff', '--no-textconv',
|
||||
);
|
||||
if (options.staged) args.push('--staged');
|
||||
if (options.file) args.push('--', options.file);
|
||||
return args;
|
||||
}
|
||||
|
||||
function resolveWorkspaceRepository(workspace: string): string | null {
|
||||
try {
|
||||
const workspaceRoot = fs.realpathSync.native(workspace);
|
||||
const env = gitEnvironment(workspaceRoot);
|
||||
const discovered = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
||||
cwd: workspaceRoot,
|
||||
env,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 10_000,
|
||||
}).trim();
|
||||
const repoRoot = fs.realpathSync.native(discovered);
|
||||
const normalizedWorkspace = path.normalize(workspaceRoot);
|
||||
const normalizedRepo = path.normalize(repoRoot);
|
||||
const matches = process.platform === 'win32'
|
||||
? normalizedWorkspace.toLowerCase() === normalizedRepo.toLowerCase()
|
||||
: normalizedWorkspace === normalizedRepo;
|
||||
return matches ? repoRoot : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function runGitInRepo(
|
||||
repoRoot: string,
|
||||
args: string[],
|
||||
timeoutMs = 10_000,
|
||||
env = gitEnvironment(repoRoot),
|
||||
): string {
|
||||
try {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
encoding: 'utf-8',
|
||||
timeout: timeoutMs,
|
||||
}).trim();
|
||||
} catch (err: unknown) {
|
||||
return spawnErrorText(err);
|
||||
}
|
||||
}
|
||||
|
||||
function runGit(workspace: string, args: string[], timeoutMs = 10_000): string {
|
||||
const repoRoot = resolveWorkspaceRepository(workspace);
|
||||
return repoRoot ? runGitInRepo(repoRoot, args, timeoutMs) : NO_WORKSPACE_REPO;
|
||||
}
|
||||
|
||||
/** Run an arbitrary command (for gh CLI). Returns stdout or error text. */
|
||||
function runCmd(cmd: string, cmdArgs: string[], cwd: string, timeoutMs = 60_000): string {
|
||||
try {
|
||||
return execFileSync(cmd, cmdArgs, { cwd, encoding: 'utf-8', timeout: timeoutMs }).trim();
|
||||
return execFileSync(cmd, cmdArgs, {
|
||||
cwd,
|
||||
env: gitEnvironment(cwd),
|
||||
encoding: 'utf-8',
|
||||
timeout: timeoutMs,
|
||||
}).trim();
|
||||
} catch (err: unknown) {
|
||||
return spawnErrorText(err);
|
||||
}
|
||||
@@ -44,8 +166,10 @@ export function createGitTools(workspace: string): ToolDefinition[] {
|
||||
offlineCapable: true,
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute: async () => {
|
||||
const branch = runGit(workspace, ['branch', '--show-current']);
|
||||
const status = runGit(workspace, ['status', '--short']);
|
||||
const repoRoot = resolveWorkspaceRepository(workspace);
|
||||
if (!repoRoot) return NO_WORKSPACE_REPO;
|
||||
const branch = runGitInRepo(repoRoot, ['branch', '--show-current']);
|
||||
const status = runGitInRepo(repoRoot, ['status', '--short']);
|
||||
return `Branch: ${branch || '(no branch)'}\n${status || 'Clean'}`;
|
||||
},
|
||||
},
|
||||
@@ -61,10 +185,19 @@ export function createGitTools(workspace: string): ToolDefinition[] {
|
||||
},
|
||||
},
|
||||
execute: async (args) => {
|
||||
const gitArgs = ['diff'];
|
||||
if (args.staged) gitArgs.push('--staged');
|
||||
if (args.file) gitArgs.push(args.file as string);
|
||||
const diff = runGit(workspace, gitArgs);
|
||||
const repoRoot = resolveWorkspaceRepository(workspace);
|
||||
if (!repoRoot) return NO_WORKSPACE_REPO;
|
||||
const env = gitDiffEnvironment(repoRoot);
|
||||
let gitArgs: string[];
|
||||
try {
|
||||
gitArgs = buildReadOnlyGitDiffArgs(repoRoot, env, {
|
||||
staged: Boolean(args.staged),
|
||||
file: args.file as string | undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
return `Error: git_diff filter safety check failed: ${spawnErrorText(err)}`;
|
||||
}
|
||||
const diff = runGitInRepo(repoRoot, gitArgs, 10_000, env);
|
||||
return diff || 'No changes.';
|
||||
},
|
||||
},
|
||||
@@ -267,17 +400,19 @@ export function createGitTools(workspace: string): ToolDefinition[] {
|
||||
const body = (args.body as string) || '';
|
||||
const base = (args.base as string) || 'main';
|
||||
const draft = args.draft as boolean | undefined;
|
||||
const repoRoot = resolveWorkspaceRepository(workspace);
|
||||
if (!repoRoot) return NO_WORKSPACE_REPO;
|
||||
|
||||
if (isAvailable('gh')) {
|
||||
const ghArgs = ['pr', 'create', '--title', title, '--base', base];
|
||||
if (body) ghArgs.push('--body', body);
|
||||
if (draft) ghArgs.push('--draft');
|
||||
return runCmd('gh', ghArgs, workspace, 60_000);
|
||||
return runCmd('gh', ghArgs, repoRoot, 60_000);
|
||||
}
|
||||
|
||||
// gh CLI not available — generate formatted PR description for manual use
|
||||
const currentBranch = runGit(workspace, ['branch', '--show-current']);
|
||||
const recentLog = runGit(workspace, ['log', '--oneline', `${base}..HEAD`, '-20']);
|
||||
const currentBranch = runGitInRepo(repoRoot, ['branch', '--show-current']);
|
||||
const recentLog = runGitInRepo(repoRoot, ['log', '--oneline', `${base}..HEAD`, '-20']);
|
||||
return [
|
||||
`## Pull Request (manual — gh CLI not found)`,
|
||||
'',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ export interface HookContext {
|
||||
export interface HookResult {
|
||||
cancelled: boolean;
|
||||
reason?: string;
|
||||
authorized?: true;
|
||||
}
|
||||
|
||||
export interface HookActivityEntry {
|
||||
@@ -37,7 +38,7 @@ export interface HookActivityEntry {
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export type HookFn = (ctx: HookContext) => Promise<{ cancel?: boolean; reason?: string } | void> | { cancel?: boolean; reason?: string } | void;
|
||||
export type HookFn = (ctx: HookContext) => Promise<{ cancel?: boolean; reason?: string; authorize?: true } | void> | { cancel?: boolean; reason?: string; authorize?: true } | void;
|
||||
|
||||
export class HookRegistry {
|
||||
private hooks = new Map<HookEvent, Set<HookFn>>();
|
||||
@@ -67,17 +68,19 @@ export class HookRegistry {
|
||||
}
|
||||
|
||||
async fire(event: HookEvent, ctx: HookContext): Promise<HookResult> {
|
||||
let authorized = false;
|
||||
if (this.parent) {
|
||||
const inherited = await this.parent.fire(event, ctx);
|
||||
if (inherited.cancelled) {
|
||||
this.recordActivity(event, true, inherited.reason, ctx.workspaceId);
|
||||
return inherited;
|
||||
}
|
||||
authorized = inherited.authorized === true;
|
||||
}
|
||||
const fns = this.hooks.get(event);
|
||||
if (!fns || fns.size === 0) {
|
||||
this.recordActivity(event, false, undefined, ctx.workspaceId);
|
||||
return { cancelled: false };
|
||||
return authorized ? { cancelled: false, authorized: true } : { cancelled: false };
|
||||
}
|
||||
|
||||
for (const fn of fns) {
|
||||
@@ -87,12 +90,13 @@ export class HookRegistry {
|
||||
this.recordActivity(event, true, result.reason, ctx.workspaceId);
|
||||
return { cancelled: true, reason: result.reason };
|
||||
}
|
||||
if (result?.authorize === true) authorized = true;
|
||||
} catch {
|
||||
// Hook errors are non-fatal — log but continue
|
||||
}
|
||||
}
|
||||
this.recordActivity(event, false, undefined, ctx.workspaceId);
|
||||
return { cancelled: false };
|
||||
return authorized ? { cancelled: false, authorized: true } : { cancelled: false };
|
||||
}
|
||||
|
||||
getActivityLog(): readonly HookActivityEntry[] {
|
||||
|
||||
@@ -19,8 +19,13 @@ export {
|
||||
} from './model-router.js';
|
||||
export {
|
||||
openaiChat,
|
||||
parseOpenAiTextCompletion,
|
||||
isIncompleteCompletionError,
|
||||
type ChatMessage,
|
||||
type ChatResponse,
|
||||
type CompletionUsage,
|
||||
type IncompleteCompletionError,
|
||||
type ParsedOpenAiTextCompletion,
|
||||
} from './providers/openai-compat.js';
|
||||
export {
|
||||
classifyRateLimitError,
|
||||
@@ -55,6 +60,14 @@ export {
|
||||
type AgentRunProgressEventType,
|
||||
type AgentRunProgressCallback,
|
||||
} from './agent-loop.js';
|
||||
export {
|
||||
selectAgentRunBudget,
|
||||
capToolResultForModel,
|
||||
compactToolContextForModel,
|
||||
type AgentRunBudgetInput,
|
||||
type AgentRunBudgetPolicy,
|
||||
type ToolContextBudget,
|
||||
} from './agent-run-budget.js';
|
||||
|
||||
// Phase 1.2 — model-aware prompt shapes (oversight in original Phase 1.2:
|
||||
// re-export to public API was missing; surfaced + fixed during Phase 2.3
|
||||
@@ -309,7 +322,20 @@ export { Plan, type PlanStep } from './plan.js';
|
||||
export { createPlanTools } from './plan-tools.js';
|
||||
export { createGitTools } from './git-tools.js';
|
||||
export { PermissionManager, READONLY_TOOLS } from './permissions.js';
|
||||
export { filterToolsForContext, filterAvailableTools, filterOfflineTools, getOfflineCapableToolNames, type ToolContext, type ToolFilterConfig } from './tool-filter.js';
|
||||
export {
|
||||
DEFAULT_TURN_SCHEMA_CHAR_LIMIT,
|
||||
DEFAULT_TURN_TOOL_LIMIT,
|
||||
filterToolsForContext,
|
||||
filterAvailableTools,
|
||||
filterOfflineTools,
|
||||
getOfflineCapableToolNames,
|
||||
measureOpenAiToolSchemaChars,
|
||||
selectToolsForTurn,
|
||||
type ToolContext,
|
||||
type ToolFilterConfig,
|
||||
type TurnToolSelectionOptions,
|
||||
type TurnToolSelectionResult,
|
||||
} from './tool-filter.js';
|
||||
export {
|
||||
needsConfirmation, needsConfirmationWithAutonomy, isCriticalNeverAutopass,
|
||||
ConfirmationGate, getApprovalClass, classifyGatedToolRisk,
|
||||
@@ -347,7 +373,7 @@ export { WORKFLOW_TEMPLATES, listWorkflowTemplates, createResearchTeamTemplate,
|
||||
export { loadCustomWorkflows, saveCustomWorkflow, deleteCustomWorkflow, listAllWorkflows } from './custom-workflows.js';
|
||||
export { createWorkflowTools, type WorkflowToolsConfig } from './workflow-tools.js';
|
||||
export { detectTaskShape, type TaskShape, type TaskShapeType, type TaskShapeSignal, type ComponentPhase } from './task-shape.js';
|
||||
export { PromptAssembler, type AssembledPrompt, type AssembleOptions, type AssembleInput, type ScaffoldStyle } from './prompt-assembler.js';
|
||||
export { PromptAssembler, CLOSED_WORLD_REWRITE_CONTRACT, isClosedWorldRewriteRequest, type AssembledPrompt, type AssembleOptions, type AssembleInput, type ScaffoldStyle } from './prompt-assembler.js';
|
||||
export {
|
||||
composeWorkflow, validateTemplate,
|
||||
type WorkflowPlan, type ExecutionMode, type PlanStep as ComposerPlanStep, type ComposerContext, type ValidationError,
|
||||
@@ -362,7 +388,10 @@ export {
|
||||
type KvarkAskResponseLike, type KvarkStructuredResult, type KvarkFeedbackResponseLike, type KvarkActionResponseLike,
|
||||
} from './kvark-tools.js';
|
||||
export { PERSONAS, getPersona, listPersonas, composePersonaPrompt, setPersonaDataDir, type AgentPersona } from './personas.js';
|
||||
export { loadCustomPersonas, saveCustomPersona, deleteCustomPersona } from './custom-personas.js';
|
||||
export {
|
||||
loadCustomPersonas, saveCustomPersona, deleteCustomPersona,
|
||||
isValidCustomPersonaId, assertValidCustomPersonaId,
|
||||
} from './custom-personas.js';
|
||||
export { AgentMessageBus, type AgentMessage as BusAgentMessage } from './agent-message-bus.js';
|
||||
export { createAgentCommsTools } from './agent-comms-tools.js';
|
||||
export { createCliTools, type CliToolsConfig } from './cli-tools.js';
|
||||
@@ -524,6 +553,7 @@ export {
|
||||
type HookRuntimePaths,
|
||||
type WaggleRuntimePaths,
|
||||
} from './tool-launcher.js';
|
||||
export { spawnSidecarOwnedProcess } from './sidecar-owned-process.js';
|
||||
export {
|
||||
ToolProcessTracker,
|
||||
type TrackedProcess,
|
||||
|
||||
@@ -22,7 +22,12 @@
|
||||
* reading as the conversation loop it conceptually is.
|
||||
*/
|
||||
|
||||
import { assertsUnverifiedCompletion, VERIFICATION_GATE_DIRECTIVE } from './verification-gate.js';
|
||||
import {
|
||||
assertsUnverifiedCompletion,
|
||||
isVerificationToolName,
|
||||
VERIFICATION_GATE_DIRECTIVE,
|
||||
VERIFICATION_NO_TOOL_DISCLOSURE,
|
||||
} from './verification-gate.js';
|
||||
import { planSkillDistillation } from './skill-distillation.js';
|
||||
import { logTurnEvent } from './turn-context.js';
|
||||
|
||||
@@ -72,8 +77,12 @@ export interface MaybeFireCompletionGateArgs {
|
||||
content: string;
|
||||
/** Names of tools used so far in this run (D1 reads length+set; D3 reads set for verification-class) */
|
||||
toolsUsed: readonly string[];
|
||||
/** Names of tools the model can actually call in this run. */
|
||||
availableToolNames?: readonly string[];
|
||||
/** Caller's message history — pushed to in-place when a gate fires */
|
||||
messages: GateMessage[];
|
||||
/** Current user-authored request, captured before internal directives are added. */
|
||||
userRequest?: string;
|
||||
/** Current gate state (returned with one-shot flags flipped if a gate fires) */
|
||||
state: GateState;
|
||||
/** Default true — set false to opt out of D3 */
|
||||
@@ -99,6 +108,8 @@ export interface GateResult {
|
||||
fired: boolean;
|
||||
/** New state object — copy of input state with one-shot flags + preserved answer updated. */
|
||||
state: GateState;
|
||||
/** Deterministic local suffix used when a claim cannot be verified by any available tool. */
|
||||
contentSuffix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,34 +121,53 @@ export async function maybeFireCompletionGate(args: MaybeFireCompletionGateArgs)
|
||||
const {
|
||||
content,
|
||||
toolsUsed,
|
||||
availableToolNames = [],
|
||||
messages,
|
||||
userRequest = '',
|
||||
state,
|
||||
enableVerification = true,
|
||||
enableSkillDistillation = true,
|
||||
onSkillDistillationFire,
|
||||
turnId,
|
||||
} = args;
|
||||
let nextState = state;
|
||||
let contentSuffix: string | undefined;
|
||||
|
||||
// ── D3 verification-before-completion gate ──
|
||||
if (
|
||||
enableVerification &&
|
||||
!state.verificationCorrectionUsed &&
|
||||
assertsUnverifiedCompletion(content, toolsUsed)
|
||||
assertsUnverifiedCompletion(content, toolsUsed, userRequest)
|
||||
) {
|
||||
messages.push({ role: 'assistant', content });
|
||||
messages.push({ role: 'user', content: VERIFICATION_GATE_DIRECTIVE });
|
||||
logTurnEvent(turnId, { stage: 'agent-loop.verification-gate.fired', contentChars: content.length });
|
||||
return {
|
||||
fired: true,
|
||||
state: { ...state, verificationCorrectionUsed: true },
|
||||
};
|
||||
if (!availableToolNames.some(isVerificationToolName)) {
|
||||
logTurnEvent(turnId, {
|
||||
stage: 'agent-loop.verification-gate.disclosed',
|
||||
contentChars: content.length,
|
||||
});
|
||||
contentSuffix = VERIFICATION_NO_TOOL_DISCLOSURE;
|
||||
nextState = { ...state, verificationCorrectionUsed: true };
|
||||
} else {
|
||||
const systemMessage = messages.find(message => message.role === 'system');
|
||||
const internalDirective = `\n\n# Internal verification correction\n${VERIFICATION_GATE_DIRECTIVE}`;
|
||||
if (systemMessage && typeof systemMessage.content === 'string') {
|
||||
systemMessage.content += internalDirective;
|
||||
} else {
|
||||
messages.unshift({ role: 'system', content: internalDirective.trim() });
|
||||
}
|
||||
logTurnEvent(turnId, { stage: 'agent-loop.verification-gate.fired', contentChars: content.length });
|
||||
return {
|
||||
fired: true,
|
||||
state: { ...state, verificationCorrectionUsed: true },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── D1 Hermes-parity closed learning loop (mechanical closure) ──
|
||||
if (enableSkillDistillation && !state.skillDistillationUsed) {
|
||||
const distillPlan = planSkillDistillation(toolsUsed, content);
|
||||
if (enableSkillDistillation && !nextState.skillDistillationUsed) {
|
||||
const acceptedContent = `${content}${contentSuffix ?? ''}`;
|
||||
const distillPlan = planSkillDistillation(toolsUsed, acceptedContent);
|
||||
if (distillPlan) {
|
||||
messages.push({ role: 'assistant', content });
|
||||
messages.push({ role: 'assistant', content: acceptedContent });
|
||||
messages.push({ role: 'user', content: distillPlan.directive });
|
||||
logTurnEvent(turnId, { stage: 'agent-loop.skill-distillation.fired', toolCalls: toolsUsed.length });
|
||||
|
||||
@@ -157,15 +187,16 @@ export async function maybeFireCompletionGate(args: MaybeFireCompletionGateArgs)
|
||||
|
||||
return {
|
||||
fired: true,
|
||||
contentSuffix,
|
||||
state: {
|
||||
...state,
|
||||
...nextState,
|
||||
skillDistillationUsed: true,
|
||||
preservedAnswerForDistillation: content,
|
||||
preservedAnswerForDistillation: acceptedContent,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No gate fired — caller can accept completion.
|
||||
return { fired: false, state };
|
||||
return { fired: false, state: nextState, contentSuffix };
|
||||
}
|
||||
|
||||
@@ -14,8 +14,14 @@
|
||||
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import type { ToolDefinition } from './tools.js';
|
||||
import { resolveToolCommandInvocationFromPath } from './tool-command.js';
|
||||
import { createSanitizedEnv } from './system-tools-helpers.js';
|
||||
import {
|
||||
spawnSidecarOwnedProcess,
|
||||
type SidecarOwnedProcessOptions,
|
||||
} from './sidecar-owned-process.js';
|
||||
|
||||
// ── Minimal LSP/JSON-RPC wire shapes (only the fields we read) ──
|
||||
interface LspPosition { line?: number; character?: number }
|
||||
@@ -45,6 +51,80 @@ let requestId = 0;
|
||||
let pendingRequests = new Map<number, PendingRequest>();
|
||||
let receiveBuffer = '';
|
||||
|
||||
export interface LspSpawnDeps {
|
||||
resolveCommand?: typeof resolveToolCommandInvocationFromPath;
|
||||
spawnOwned?: (
|
||||
executable: string,
|
||||
args: string[],
|
||||
options: SidecarOwnedProcessOptions,
|
||||
) => ChildProcess;
|
||||
}
|
||||
|
||||
export async function spawnLspServerProcess(
|
||||
workspacePath: string,
|
||||
deps: LspSpawnDeps = {},
|
||||
): Promise<ChildProcess> {
|
||||
const env = createSanitizedEnv();
|
||||
const invocation = await (deps.resolveCommand ?? resolveToolCommandInvocationFromPath)(
|
||||
'typescript-language-server',
|
||||
['--stdio'],
|
||||
process.platform,
|
||||
{ env },
|
||||
);
|
||||
return (deps.spawnOwned ?? spawnSidecarOwnedProcess)(
|
||||
invocation.binary,
|
||||
invocation.args,
|
||||
{
|
||||
cwd: workspacePath,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopLspServerProcess(
|
||||
child: ChildProcess,
|
||||
timeoutMs = 6_500,
|
||||
): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise<void>((resolveStop, rejectStop) => {
|
||||
let settled = false;
|
||||
const finish = (error?: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.removeListener('exit', onExit);
|
||||
child.removeListener('error', onError);
|
||||
if (error) rejectStop(error);
|
||||
else resolveStop();
|
||||
};
|
||||
const onExit = (): void => finish();
|
||||
const onError = (error: Error): void => finish(error);
|
||||
const timer = setTimeout(
|
||||
() => finish(new Error('Timed out while stopping the sidecar-owned LSP process tree')),
|
||||
timeoutMs,
|
||||
);
|
||||
timer.unref();
|
||||
child.once('exit', onExit);
|
||||
child.once('error', onError);
|
||||
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (!child.connected || typeof child.send !== 'function') return;
|
||||
try {
|
||||
child.send('shutdown', (error) => {
|
||||
if (error) finish(error);
|
||||
});
|
||||
} catch (error) {
|
||||
finish(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Reset module-level state (for testing). */
|
||||
export function _resetLspState(): void {
|
||||
lspProcess = null;
|
||||
@@ -133,19 +213,9 @@ function handleData(data: string): void {
|
||||
async function ensureLsp(workspacePath: string): Promise<void> {
|
||||
if (lspProcess && lspInitialized && lspWorkspace === workspacePath) return;
|
||||
|
||||
// Check if typescript-language-server is available
|
||||
const tsServerCmd = process.platform === 'win32'
|
||||
? 'typescript-language-server.cmd'
|
||||
: 'typescript-language-server';
|
||||
|
||||
// Try to spawn
|
||||
try {
|
||||
lspProcess = spawn(tsServerCmd, ['--stdio'], {
|
||||
cwd: workspacePath,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
lspProcess = await spawnLspServerProcess(workspacePath);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'LSP requires typescript-language-server. Install with: npm install -g typescript-language-server typescript',
|
||||
@@ -203,15 +273,16 @@ async function ensureLsp(workspacePath: string): Promise<void> {
|
||||
/** Stop the LSP server. */
|
||||
async function stopLsp(): Promise<void> {
|
||||
if (lspProcess) {
|
||||
const processToStop = lspProcess;
|
||||
try {
|
||||
sendNotification('shutdown', {});
|
||||
sendNotification('exit', {});
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
lspProcess.kill();
|
||||
lspProcess = null;
|
||||
lspInitialized = false;
|
||||
await stopLspServerProcess(processToStop);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { spawn, type StdioOptions } from 'node:child_process';
|
||||
import { ChildProcess, type StdioOptions } from 'node:child_process';
|
||||
import type { Readable, Writable } from 'stream';
|
||||
import type { RiskLevel } from '@waggle/shared';
|
||||
import type { ToolDefinition } from '../tools.js';
|
||||
import { scanForInjection } from '../injection-scanner.js';
|
||||
import { resolveToolCommandInvocationFromPath } from '../tool-command.js';
|
||||
import { createSanitizedEnv, terminateProcessTreeAndWait } from '../system-tools-helpers.js';
|
||||
import { spawnSidecarOwnedProcess } from '../sidecar-owned-process.js';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,6 +24,26 @@ export interface McpToolInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
annotations?: {
|
||||
title?: string;
|
||||
readOnlyHint?: boolean;
|
||||
destructiveHint?: boolean;
|
||||
idempotentHint?: boolean;
|
||||
openWorldHint?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function classifyMcpToolRisk(tool: McpToolInfo): RiskLevel {
|
||||
// MCP servers currently have no independently verified trust provenance.
|
||||
// Their annotations may elevate risk, but can never lower the high floor
|
||||
// required by automated/sub-agent execution contexts without a human gate.
|
||||
return tool.annotations?.destructiveHint === true ? 'critical' : 'high';
|
||||
}
|
||||
|
||||
/** JSON-RPC 2.0 request/response types */
|
||||
@@ -51,9 +76,16 @@ export interface McpProcess {
|
||||
export type SpawnFn = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { env?: Record<string, string>; stdio: string[] },
|
||||
options: {
|
||||
env?: Record<string, string>;
|
||||
stdio: string[];
|
||||
windowsVerbatimArguments?: boolean;
|
||||
},
|
||||
) => McpProcess;
|
||||
|
||||
/** Returns true only when process settlement has been confirmed. */
|
||||
export type McpTerminateFn = (process: McpProcess) => boolean | Promise<boolean>;
|
||||
|
||||
// ── McpServerInstance ──────────────────────────────────────────────────
|
||||
|
||||
export class McpServerInstance extends EventEmitter {
|
||||
@@ -61,6 +93,7 @@ export class McpServerInstance extends EventEmitter {
|
||||
private state: McpServerState = 'stopped';
|
||||
private process: McpProcess | null = null;
|
||||
private spawnFn: SpawnFn;
|
||||
private terminateFn: McpTerminateFn;
|
||||
private nextId = 1;
|
||||
private pendingRequests = new Map<number, {
|
||||
resolve: (value: unknown) => void;
|
||||
@@ -69,6 +102,7 @@ export class McpServerInstance extends EventEmitter {
|
||||
}>();
|
||||
private tools: McpToolInfo[] = [];
|
||||
private stdoutBuffer = '';
|
||||
private lifecycleGeneration = 0;
|
||||
private autoRestart: boolean;
|
||||
private toolCallTimeoutMs: number;
|
||||
|
||||
@@ -76,6 +110,7 @@ export class McpServerInstance extends EventEmitter {
|
||||
config: McpServerConfig,
|
||||
options?: {
|
||||
spawn?: SpawnFn;
|
||||
terminate?: McpTerminateFn;
|
||||
autoRestart?: boolean;
|
||||
toolCallTimeoutMs?: number;
|
||||
},
|
||||
@@ -83,6 +118,7 @@ export class McpServerInstance extends EventEmitter {
|
||||
super();
|
||||
this.config = config;
|
||||
this.spawnFn = options?.spawn ?? defaultSpawn;
|
||||
this.terminateFn = options?.terminate ?? defaultTerminate;
|
||||
this.autoRestart = options?.autoRestart ?? false;
|
||||
this.toolCallTimeoutMs = options?.toolCallTimeoutMs ?? 30_000;
|
||||
}
|
||||
@@ -101,16 +137,31 @@ export class McpServerInstance extends EventEmitter {
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.state === 'ready' || this.state === 'starting') return;
|
||||
if (this.process) {
|
||||
throw new Error(
|
||||
`Cannot start MCP server "${this.config.name}": previous process termination is unconfirmed`,
|
||||
);
|
||||
}
|
||||
|
||||
const generation = ++this.lifecycleGeneration;
|
||||
this.setState('starting');
|
||||
|
||||
try {
|
||||
this.process = this.spawnFn(
|
||||
const env = createMcpEnvironment(this.config.env);
|
||||
const invocation = await resolveToolCommandInvocationFromPath(
|
||||
this.config.command,
|
||||
this.config.args ?? [],
|
||||
process.platform,
|
||||
{ env },
|
||||
);
|
||||
if (generation !== this.lifecycleGeneration) return;
|
||||
this.process = this.spawnFn(
|
||||
invocation.binary,
|
||||
invocation.args,
|
||||
{
|
||||
env: this.config.env ? { ...process.env, ...this.config.env } as Record<string, string> : undefined,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -161,8 +212,13 @@ export class McpServerInstance extends EventEmitter {
|
||||
this.process.stdout?.removeAllListeners('data');
|
||||
this.process.removeAllListeners('exit');
|
||||
this.process.removeAllListeners('error');
|
||||
this.process.kill();
|
||||
this.process = null;
|
||||
const failedProcess = this.process;
|
||||
try {
|
||||
const settled = await this.terminateFn(failedProcess);
|
||||
if (settled && this.process === failedProcess) this.process = null;
|
||||
} catch {
|
||||
// Retain the handle so a later stop/remove can retry revocation.
|
||||
}
|
||||
}
|
||||
this.tools = [];
|
||||
this.stdoutBuffer = '';
|
||||
@@ -189,25 +245,36 @@ export class McpServerInstance extends EventEmitter {
|
||||
async stop(): Promise<void> {
|
||||
if (this.state === 'stopped') return;
|
||||
|
||||
this.lifecycleGeneration++;
|
||||
// Prevent auto-restart during intentional stop
|
||||
const wasAutoRestart = this.autoRestart;
|
||||
this.autoRestart = false;
|
||||
|
||||
this.rejectAllPending(new Error('Server stopping'));
|
||||
try {
|
||||
this.rejectAllPending(new Error('Server stopping'));
|
||||
|
||||
if (this.process) {
|
||||
this.process.stdout?.removeAllListeners('data');
|
||||
this.process.removeAllListeners('exit');
|
||||
this.process.removeAllListeners('error');
|
||||
this.process.stdin?.end();
|
||||
this.process.kill();
|
||||
this.process = null;
|
||||
if (this.process) {
|
||||
this.process.stdout?.removeAllListeners('data');
|
||||
this.process.removeAllListeners('exit');
|
||||
this.process.removeAllListeners('error');
|
||||
this.process.stdin?.end();
|
||||
const stoppingProcess = this.process;
|
||||
const settled = await this.terminateFn(stoppingProcess);
|
||||
if (!settled) {
|
||||
throw new Error('MCP process termination could not be confirmed');
|
||||
}
|
||||
if (this.process === stoppingProcess) this.process = null;
|
||||
}
|
||||
|
||||
this.tools = [];
|
||||
this.stdoutBuffer = '';
|
||||
this.setState('stopped');
|
||||
} catch (err) {
|
||||
this.setState('error');
|
||||
throw err;
|
||||
} finally {
|
||||
this.autoRestart = wasAutoRestart;
|
||||
}
|
||||
|
||||
this.tools = [];
|
||||
this.stdoutBuffer = '';
|
||||
this.setState('stopped');
|
||||
this.autoRestart = wasAutoRestart;
|
||||
}
|
||||
|
||||
async callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
@@ -326,16 +393,19 @@ export class McpRuntime extends EventEmitter {
|
||||
private servers = new Map<string, McpServerInstance>();
|
||||
private configs = new Map<string, McpServerConfig>();
|
||||
private spawnFn: SpawnFn;
|
||||
private terminateFn: McpTerminateFn;
|
||||
private autoRestart: boolean;
|
||||
private toolCallTimeoutMs: number;
|
||||
|
||||
constructor(options?: {
|
||||
spawn?: SpawnFn;
|
||||
terminate?: McpTerminateFn;
|
||||
autoRestart?: boolean;
|
||||
toolCallTimeoutMs?: number;
|
||||
}) {
|
||||
super();
|
||||
this.spawnFn = options?.spawn ?? defaultSpawn;
|
||||
this.terminateFn = options?.terminate ?? defaultTerminate;
|
||||
this.autoRestart = options?.autoRestart ?? false;
|
||||
this.toolCallTimeoutMs = options?.toolCallTimeoutMs ?? 30_000;
|
||||
}
|
||||
@@ -347,6 +417,7 @@ export class McpRuntime extends EventEmitter {
|
||||
this.configs.set(config.name, config);
|
||||
const instance = new McpServerInstance(config, {
|
||||
spawn: this.spawnFn,
|
||||
terminate: this.terminateFn,
|
||||
autoRestart: this.autoRestart,
|
||||
toolCallTimeoutMs: this.toolCallTimeoutMs,
|
||||
});
|
||||
@@ -359,13 +430,13 @@ export class McpRuntime extends EventEmitter {
|
||||
this.servers.set(config.name, instance);
|
||||
}
|
||||
|
||||
removeServer(name: string): Promise<void> {
|
||||
async removeServer(name: string): Promise<void> {
|
||||
const server = this.servers.get(name);
|
||||
if (!server) return Promise.resolve();
|
||||
if (!server) return;
|
||||
|
||||
await server.stop();
|
||||
this.servers.delete(name);
|
||||
this.configs.delete(name);
|
||||
return server.stop();
|
||||
}
|
||||
|
||||
getServer(name: string): McpServerInstance | undefined {
|
||||
@@ -440,15 +511,35 @@ export class McpRuntime extends EventEmitter {
|
||||
|
||||
private wrapServerTools(server: McpServerInstance): ToolDefinition[] {
|
||||
const serverName = server.config.name;
|
||||
return server.getTools().map((tool) => ({
|
||||
name: `mcp_${serverName}_${tool.name}`,
|
||||
description: `[MCP: ${serverName}] ${tool.description}`,
|
||||
parameters: tool.inputSchema,
|
||||
execute: async (args: Record<string, unknown>) => {
|
||||
const result = await server.callTool(tool.name, args);
|
||||
return typeof result === 'string' ? result : JSON.stringify(result);
|
||||
},
|
||||
}));
|
||||
const tools: ToolDefinition[] = [];
|
||||
for (const tool of server.getTools()) {
|
||||
if (typeof tool.description !== 'string' || !isPlainRecord(tool.inputSchema)) continue;
|
||||
|
||||
let serializedInputSchema: string;
|
||||
let normalizedInputSchema: unknown;
|
||||
try {
|
||||
serializedInputSchema = JSON.stringify(tool.inputSchema);
|
||||
normalizedInputSchema = JSON.parse(serializedInputSchema) as unknown;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!isPlainRecord(normalizedInputSchema)) continue;
|
||||
|
||||
const description = tool.description;
|
||||
if (!scanForInjection(`${description}\n${serializedInputSchema}`, 'tool_output').safe) continue;
|
||||
|
||||
tools.push({
|
||||
name: `mcp_${serverName}_${tool.name}`,
|
||||
description: `[UNTRUSTED MCP: ${serverName}] ${description}`,
|
||||
parameters: normalizedInputSchema,
|
||||
riskLevel: classifyMcpToolRisk(tool),
|
||||
execute: async (args: Record<string, unknown>) => {
|
||||
const result = await server.callTool(tool.name, args);
|
||||
return typeof result === 'string' ? result : JSON.stringify(result);
|
||||
},
|
||||
});
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,10 +548,31 @@ export class McpRuntime extends EventEmitter {
|
||||
function defaultSpawn(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { env?: Record<string, string>; stdio: string[] },
|
||||
options: {
|
||||
env?: Record<string, string>;
|
||||
stdio: string[];
|
||||
windowsVerbatimArguments?: boolean;
|
||||
},
|
||||
): McpProcess {
|
||||
return spawn(command, args, {
|
||||
return spawnSidecarOwnedProcess(command, args, {
|
||||
env: options.env as NodeJS.ProcessEnv | undefined,
|
||||
stdio: options.stdio as StdioOptions,
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: options.windowsVerbatimArguments === true,
|
||||
}) as unknown as McpProcess;
|
||||
}
|
||||
|
||||
function createMcpEnvironment(explicit?: Record<string, string>): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(createSanitizedEnv())) {
|
||||
if (value !== undefined) env[key] = value;
|
||||
}
|
||||
return { ...env, ...(explicit ?? {}) };
|
||||
}
|
||||
|
||||
async function defaultTerminate(process: McpProcess): Promise<boolean> {
|
||||
if (process instanceof ChildProcess) {
|
||||
return terminateProcessTreeAndWait(process);
|
||||
}
|
||||
return process.kill();
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -10,6 +10,7 @@ import {
|
||||
KnowledgeGraph,
|
||||
ImprovementSignalStore,
|
||||
createCoreLogger,
|
||||
evaluateExternalMemoryIngress,
|
||||
type Embedder,
|
||||
TEMPORAL_GUIDANCE,
|
||||
renderReferenceDateLine,
|
||||
@@ -52,6 +53,7 @@ import { logTurnEvent } from './turn-context.js';
|
||||
import { tierForModel, type ModelTier } from './model-tier.js';
|
||||
import type { AgentPersona } from './personas.js';
|
||||
import {
|
||||
isClosedWorldRewriteRequest,
|
||||
PromptAssembler,
|
||||
type AssembleOptions,
|
||||
type AssembledPrompt,
|
||||
@@ -84,6 +86,8 @@ export interface OrchestratorConfig {
|
||||
* creation failure soft-fails to RRF-only ordering.
|
||||
*/
|
||||
reranker?: Reranker;
|
||||
/** Optional managed cache root for the lazy in-process reranker model. */
|
||||
rerankerCacheDir?: string;
|
||||
/** AI-OS #6 — durable "why" breadcrumb injected into buildSystemPrompt. */
|
||||
goalAncestry?: GoalAncestry;
|
||||
}
|
||||
@@ -143,6 +147,7 @@ export class Orchestrator {
|
||||
private cognify: CognifyPipeline;
|
||||
/** W4.2: memoized reranker promise — resolves undefined on creation failure. */
|
||||
private rerankerPromise: Promise<Reranker | undefined> | null = null;
|
||||
private readonly rerankerCacheDir: string | undefined;
|
||||
|
||||
/** Team sync client — set for team workspaces, null for personal */
|
||||
private teamSync: import('@waggle/core').TeamSync | null = null;
|
||||
@@ -160,6 +165,7 @@ export class Orchestrator {
|
||||
this.mode = config.mode ?? 'local';
|
||||
this.version = config.version ?? '0.0.0';
|
||||
this.skills = config.skills ?? [];
|
||||
this.rerankerCacheDir = config.rerankerCacheDir;
|
||||
this.goalAncestry = config.goalAncestry ?? null;
|
||||
this.identity = new IdentityLayer(config.db);
|
||||
this.awareness = new AwarenessLayer(config.db);
|
||||
@@ -317,7 +323,7 @@ export class Orchestrator {
|
||||
return compute();
|
||||
}
|
||||
|
||||
buildSystemPrompt(): string {
|
||||
buildSystemPrompt(modelOverride = this.model): string {
|
||||
// ── IDENTITY (always personal, stable within a session) ──
|
||||
// Cache key must hash the full identity content — updated_at alone
|
||||
// has only second precision in SQLite, so rapid successive edits
|
||||
@@ -345,7 +351,7 @@ export class Orchestrator {
|
||||
const caps: AgentCapabilities = {
|
||||
tools: this.tools.map(t => ({ name: t.name, description: t.description })),
|
||||
skills: this.skills,
|
||||
model: this.model,
|
||||
model: modelOverride,
|
||||
memoryStats: this.getMemoryStats(),
|
||||
mode: this.mode,
|
||||
version: this.version,
|
||||
@@ -377,14 +383,31 @@ export class Orchestrator {
|
||||
async buildAssembledPrompt(
|
||||
query: string,
|
||||
persona: AgentPersona | null = null,
|
||||
opts: AssembleOptions = {},
|
||||
opts: AssembleOptions & { model?: string } = {},
|
||||
): Promise<AssembledPrompt> {
|
||||
const tier = tierForModel(this.model);
|
||||
const corePrompt = this.buildSystemPrompt();
|
||||
const context = this.loadRecentContextFrames();
|
||||
const effectiveModel = opts.model ?? this.model;
|
||||
const tier = tierForModel(effectiveModel);
|
||||
const closedWorldRewrite = isClosedWorldRewriteRequest(query);
|
||||
const corePrompt = closedWorldRewrite ? '' : this.buildSystemPrompt(effectiveModel);
|
||||
const context: ContextFramesImpl = closedWorldRewrite
|
||||
? {
|
||||
stateFrames: [],
|
||||
recentChanges: [],
|
||||
activeWork: [],
|
||||
keyEntities: [],
|
||||
personalPreferences: [],
|
||||
}
|
||||
: this.loadRecentContextFrames();
|
||||
|
||||
let recalled: RecalledMemory;
|
||||
if (opts.recalledText !== undefined) {
|
||||
if (closedWorldRewrite) {
|
||||
recalled = {
|
||||
workspace: [],
|
||||
personal: [],
|
||||
scanSafe: true,
|
||||
renderedText: '',
|
||||
};
|
||||
} else if (opts.recalledText !== undefined) {
|
||||
// W4.5 (plan bug #9-2, double-compute): the caller already ran
|
||||
// recallMemory this turn — reuse its rendered multi-lane block instead
|
||||
// of re-running the searches. recallMemory scans for injection itself
|
||||
@@ -458,7 +481,8 @@ export class Orchestrator {
|
||||
* W4.2/W4.5: lazy cross-encoder reranker — DEFAULT ON since the W4.5 live
|
||||
* smoke (real ONNX load + 58-83ms warm recalls verified through the real
|
||||
* server). Kill switch: WAGGLE_RERANKER=0. First use downloads the ~22MB
|
||||
* model (cached at ~/.hive-mind/models); creation failure (offline, OOM)
|
||||
* model (cached at the configured managed path, or ~/.hive-mind/models for
|
||||
* standalone callers); creation failure (offline, OOM)
|
||||
* memoizes undefined: recall soft-fails to RRF-only ordering, never throws.
|
||||
*/
|
||||
private getReranker(): Promise<Reranker | undefined> {
|
||||
@@ -467,7 +491,10 @@ export class Orchestrator {
|
||||
this.rerankerPromise = Promise.resolve(undefined);
|
||||
return this.rerankerPromise;
|
||||
}
|
||||
this.rerankerPromise = createInProcessReranker().catch((e: unknown) => {
|
||||
const rerankerConfig = this.rerankerCacheDir
|
||||
? { cacheDir: this.rerankerCacheDir }
|
||||
: undefined;
|
||||
this.rerankerPromise = createInProcessReranker(rerankerConfig).catch((e: unknown) => {
|
||||
logger.warn('reranker unavailable — falling back to RRF ordering', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
@@ -485,6 +512,24 @@ export class Orchestrator {
|
||||
const scoreFloor = opts?.scoreFloor;
|
||||
logTurnEvent(opts?.turnId, { stage: 'orchestrator.recallMemory.enter', queryChars: query.length, limit, profile });
|
||||
try {
|
||||
const personalHasFrames = this.db.getDatabase()
|
||||
.prepare('SELECT 1 FROM memory_frames LIMIT 1')
|
||||
.get() !== undefined;
|
||||
const workspaceHasFrames = this.workspaceLayers
|
||||
? this.workspaceLayers.db.getDatabase()
|
||||
.prepare('SELECT 1 FROM memory_frames LIMIT 1')
|
||||
.get() !== undefined
|
||||
: false;
|
||||
if (!personalHasFrames && !workspaceHasFrames) {
|
||||
logTurnEvent(opts?.turnId, {
|
||||
stage: 'orchestrator.recallMemory.exit',
|
||||
totalCount: 0,
|
||||
blocked: false,
|
||||
emptyMindFastPath: true,
|
||||
});
|
||||
return { text: '', count: 0, recalled: [], recalledFrames: [] };
|
||||
}
|
||||
|
||||
// Detect catch-up intent — these queries need importance-based recall, not literal text matching
|
||||
const catchUpPatterns = [
|
||||
/\bcatch me up\b/i, /\bwhere (?:are|were) we\b/i, /\bwhat matters\b/i,
|
||||
@@ -909,6 +954,7 @@ export class Orchestrator {
|
||||
const importance = 'normal';
|
||||
const marker = `[Session summary — ${sessionKey}]`;
|
||||
const content = `${marker}\n\n${summary}`;
|
||||
if (evaluateExternalMemoryIngress({ content }).action !== 'allow') return null;
|
||||
|
||||
const frames = this.workspaceLayers?.frames ?? this.frames;
|
||||
const cognify = this.workspaceLayers?.cognify ?? this.cognify;
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type MindDB,
|
||||
type TeamSync,
|
||||
createCoreLogger,
|
||||
evaluateExternalMemoryIngress,
|
||||
} from '@waggle/core';
|
||||
import { isSelfIncapacityAssertion } from './memory-sign-gate.js';
|
||||
import type { CognifyPipeline } from './cognify.js';
|
||||
@@ -164,6 +165,8 @@ export async function runPatternWriteBack(
|
||||
// pass 'user_stated' explicitly at their call sites.
|
||||
source: FrameSource = 'agent_inferred',
|
||||
): Promise<MemoryFrame | null> => {
|
||||
if (evaluateExternalMemoryIngress({ content }).action !== 'allow') return null;
|
||||
|
||||
// R2 sign gate (DEFECT-2): self-incapacity assertions persist at
|
||||
// 'temporary' so they're audit-visible but cannot re-enter the prompt as
|
||||
// authoritative recall (recall path excludes 'temporary').
|
||||
@@ -325,18 +328,19 @@ export async function runPatternWriteBack(
|
||||
let savedStructured = false;
|
||||
|
||||
// Inline decisions (different patterns than the explicit decision block above)
|
||||
for (const pat of INLINE_DECISION_PATTERNS) {
|
||||
const decisionLines = lines.filter(l => pat.test(l));
|
||||
if (decisionLines.length > 0 && saved.length < 5) {
|
||||
const text = decisionLines[0].replace(/^[-*\d.#]+\s*/, '').trim();
|
||||
if (text.length > 20) {
|
||||
// Confabulation-persistence guard: this is the AGENT's own assertion,
|
||||
// not a user-stated fact. Persist it audit-visible but at 'temporary'
|
||||
// so the recall path (which excludes 'temporary') can't re-surface a
|
||||
// confabulated specific as authoritative memory on a later turn.
|
||||
await save(`Recommendation: ${text.slice(0, RECALL_LINE_LENGTH)}`, 'temporary');
|
||||
inlineDecision: for (const pat of INLINE_DECISION_PATTERNS) {
|
||||
if (saved.length >= 5) break;
|
||||
for (const decisionLine of lines) {
|
||||
if (!pat.test(decisionLine)) continue;
|
||||
const text = decisionLine.replace(/^[-*\d.#]+\s*/, '').trim();
|
||||
if (text.length <= 20) continue;
|
||||
// Confabulation-persistence guard: this is the AGENT's own assertion,
|
||||
// not a user-stated fact. Persist it audit-visible but at 'temporary'
|
||||
// so the recall path (which excludes 'temporary') can't re-surface a
|
||||
// confabulated specific as authoritative memory on a later turn.
|
||||
if ((await save(`Recommendation: ${text.slice(0, RECALL_LINE_LENGTH)}`, 'temporary')) !== null) {
|
||||
savedStructured = true;
|
||||
break;
|
||||
break inlineDecision;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,8 +351,9 @@ export async function runPatternWriteBack(
|
||||
s.startsWith('User preference:') || s.startsWith('Correction from user:') || s.startsWith('Decision:')
|
||||
);
|
||||
if (!alreadyCapturedUser) {
|
||||
await save(`User asked: ${userMsg.slice(0, RECALL_LINE_LENGTH)}`, 'temporary');
|
||||
savedStructured = true;
|
||||
if ((await save(`User asked: ${userMsg.slice(0, RECALL_LINE_LENGTH)}`, 'temporary')) !== null) {
|
||||
savedStructured = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,8 +370,9 @@ export async function runPatternWriteBack(
|
||||
const prefix = heading ? `${heading}: ` : 'Key points: ';
|
||||
// Agent-extracted bullets from its own reply — audit-visible but
|
||||
// 'temporary' (recall-excluded) so confabulated specifics can't loop back.
|
||||
await save(`${prefix}${keyPoints.slice(0, FINDINGS_SLICE_LENGTH)}`, 'temporary');
|
||||
savedStructured = true;
|
||||
if ((await save(`${prefix}${keyPoints.slice(0, FINDINGS_SLICE_LENGTH)}`, 'temporary')) !== null) {
|
||||
savedStructured = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,15 +11,18 @@ import type { ToolDefinition } from './tools.js';
|
||||
// Minimal surface of the pdfmake static we use (the lib's own type export is
|
||||
// browser/vfs-coupled; we only call createPdf().getBuffer()).
|
||||
interface PdfPrinter {
|
||||
getBuffer(cb: (buffer: Buffer) => void): void;
|
||||
getBuffer(cb?: (buffer: Buffer) => void): Promise<Buffer> | void;
|
||||
}
|
||||
interface PdfMakeStatic {
|
||||
createPdf(docDef: TDocumentDefinitions): PdfPrinter;
|
||||
addVirtualFileSystem?(vfs: Record<string, string>): void;
|
||||
}
|
||||
|
||||
function resolveSafe(workspace: string, filePath: string): string {
|
||||
const resolved = path.resolve(workspace, filePath);
|
||||
if (!resolved.startsWith(path.resolve(workspace))) {
|
||||
const root = path.resolve(workspace);
|
||||
const resolved = path.resolve(root, filePath);
|
||||
const relative = path.relative(root, resolved);
|
||||
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error(`Path resolves outside workspace: ${filePath}`);
|
||||
}
|
||||
return resolved;
|
||||
@@ -198,14 +201,27 @@ export function createPdfTools(workspace: string): ToolDefinition[] {
|
||||
|
||||
const pdfMakeModule = await import('pdfmake/build/pdfmake.js');
|
||||
const pdfMake = (pdfMakeModule.default ?? pdfMakeModule) as unknown as PdfMakeStatic;
|
||||
const vfsModule = await import('pdfmake/build/vfs_fonts.js');
|
||||
const vfs = (vfsModule.default ?? vfsModule) as unknown as Record<string, string>;
|
||||
pdfMake.addVirtualFileSystem?.(vfs);
|
||||
const printer = pdfMake.createPdf(docDef);
|
||||
|
||||
const pdfBuffer = await new Promise<Buffer>((resolve, reject) => {
|
||||
printer.getBuffer((buffer: Buffer) => {
|
||||
if (buffer) resolve(buffer);
|
||||
else reject(new Error('PDF generation returned empty buffer'));
|
||||
const pdfBuffer = await (async () => {
|
||||
// pdfmake 0.3 exposes getBuffer() as a Promise; retain the
|
||||
// callback fallback for older bundled runtimes.
|
||||
if (printer.getBuffer.length === 0) {
|
||||
const result = printer.getBuffer();
|
||||
if (result && typeof (result as Promise<Buffer>).then === 'function') {
|
||||
return result as Promise<Buffer>;
|
||||
}
|
||||
}
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
printer.getBuffer((buffer: Buffer) => {
|
||||
if (buffer) resolve(buffer);
|
||||
else reject(new Error('PDF generation returned empty buffer'));
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
||||
fs.writeFileSync(resolved, pdfBuffer);
|
||||
|
||||
@@ -24,8 +24,15 @@ export const PERSONAS: AgentPersona[] = [
|
||||
systemPrompt: `## Persona: Researcher
|
||||
You specialize in deep investigation and multi-source synthesis.
|
||||
- Always cite sources when presenting findings
|
||||
- Use web_search and web_fetch for external research
|
||||
- Cross-reference memory for prior relevant findings
|
||||
- Use web_search and web_fetch for external research only when they are serialized and external research is allowed
|
||||
- Obey the requested source class and constraints. Primary sources are official docs, official repositories, original papers, standards, or first-party data — never AI summaries or aggregators.
|
||||
- For a comparison that requires primary sources, retain at least one qualifying primary-source URL for each compared item before synthesizing; fetch each source when available.
|
||||
- For current research, inspect fetched sources for archive, deprecation, or replacement notices. Prefer the maintained replacement and disclose any lifecycle warning that affects the recommendation.
|
||||
- Fetch the exact source selected from search results; do not substitute an adjacent project or an unfetched URL.
|
||||
- If a GitHub repository page yields unusable or truncated content, retry its README through the repository's exact raw.githubusercontent.com URL before declaring an evidence gap; still cite the qualifying URL retained for every compared item.
|
||||
- Attribute capabilities only to the source that states them. Never transfer features between compared products.
|
||||
- When the user asks to distinguish facts from inference, label both explicitly in the final answer.
|
||||
- Cross-reference memory only when search_memory is serialized and the evidence boundary permits it
|
||||
- Present findings in structured format with confidence levels
|
||||
- When unsure, say so and suggest further investigation paths
|
||||
- Prefer depth over breadth — thorough analysis of fewer sources beats shallow coverage of many
|
||||
@@ -42,8 +49,8 @@ Your primary job is to FIND and SYNTHESIZE information. When the user asks you t
|
||||
suggestedCommands: ['/research', '/catchup'],
|
||||
defaultWorkflow: 'research-team',
|
||||
failurePatterns: [
|
||||
'Single-source research — always triangulate across at least 3 sources',
|
||||
'Not saving findings — research not saved to memory is lost at session end. Always save before summarizing.',
|
||||
'Weak sourcing — triangulate when the task warrants it, but obey requested source constraints and never pad with secondary sources.',
|
||||
'Saving against user constraints — persist findings only when the user permits it and save_memory is available.',
|
||||
'Presenting research as conclusions — Researcher finds and synthesizes. It does not decide.',
|
||||
],
|
||||
},
|
||||
@@ -52,7 +59,7 @@ Your primary job is to FIND and SYNTHESIZE information. When the user asks you t
|
||||
name: 'Writer',
|
||||
description: 'Document drafting, editing, formatting, tone adaptation',
|
||||
icon: '✍️',
|
||||
tagline: 'Drafts, edits, and polishes — always asks about audience first.',
|
||||
tagline: 'Drafts, edits, and polishes while preserving the user’s facts and constraints.',
|
||||
bestFor: [
|
||||
'Blog posts, reports, proposals, and documentation',
|
||||
'Editing and rewriting existing content for clarity',
|
||||
@@ -61,10 +68,12 @@ Your primary job is to FIND and SYNTHESIZE information. When the user asks you t
|
||||
wontDo: 'Will not run code, execute bash commands, or manage git repositories.',
|
||||
systemPrompt: `## Persona: Writer
|
||||
You specialize in document creation, editing, and formatting.
|
||||
- Ask about audience, tone, and purpose before drafting
|
||||
- Use search_memory to find relevant context and prior work
|
||||
- Use supplied audience, tone, and purpose; ask only when materially ambiguous and follow-up is allowed
|
||||
- For a closed-world rewrite, use only the supplied text and do not add new claims, dates, roles, risks, or certainty
|
||||
- Do not append follow-up offers or file-generation CTAs when the user prohibits follow-up or files
|
||||
- Use search_memory for relevant context unless the user supplied a closed-world source or restricted evidence
|
||||
- Produce well-structured documents with clear headings and flow
|
||||
- Offer to generate Word documents (generate_docx) for formal outputs
|
||||
- Generate or offer Word documents only when the user asks for or permits a file
|
||||
- Adapt tone: professional for business, conversational for blogs, academic for papers
|
||||
- Always proofread your output before presenting it
|
||||
- Include a brief professional disclaimer ONLY when drafting content on legal, financial, medical, or regulatory topics. Do NOT add disclaimers to creative writing, general correspondence, or topics outside these domains.`,
|
||||
@@ -78,7 +87,7 @@ You specialize in document creation, editing, and formatting.
|
||||
defaultWorkflow: null,
|
||||
disallowedTools: ['bash', 'git_commit', 'git_push', 'spawn_agent'],
|
||||
failurePatterns: [
|
||||
'Drafting before gathering context — always search_memory and check relevant files FIRST.',
|
||||
'Using outside context in a closed-world rewrite — the supplied text is the complete evidence boundary.',
|
||||
'Wrong scope — "draft" means working document, "write" means near-final. Clarify when ambiguous.',
|
||||
'Ignoring workspace tone — check workspaceTone and adapt. Generic writing with established voice is failure.',
|
||||
],
|
||||
@@ -108,7 +117,7 @@ You specialize in data analysis, pattern recognition, and structured decision-ma
|
||||
### Working Style
|
||||
Your primary job is to ANALYZE data and present findings. When the user asks you to create a report document, you CAN do it — but suggest that switching to Writer might give a better result for formal deliverables. For analysis summaries and data outputs, go ahead and write.`,
|
||||
modelPreference: 'claude-sonnet-4-6',
|
||||
tools: ['bash', 'read_file', 'write_file', 'search_files', 'search_content', 'web_search', 'web_fetch', 'search_memory', 'save_memory', 'generate_docx'],
|
||||
tools: ['bash', 'read_file', 'write_file', 'search_files', 'search_content', 'web_search', 'web_fetch', 'search_memory', 'save_memory', 'generate_docx', 'generate_xlsx'],
|
||||
workspaceAffinity: ['analysis', 'data', 'strategy', 'reporting'],
|
||||
suggestedSkills: ["xlsx-generator","chart-generator"],
|
||||
suggestedConnectors: ["gsheets","postgres"],
|
||||
@@ -142,7 +151,7 @@ You specialize in software development, debugging, and code architecture.
|
||||
- Explain technical decisions when the impact isn't obvious
|
||||
- Search the codebase before writing new utilities — reuse what exists`,
|
||||
modelPreference: 'claude-sonnet-4-6',
|
||||
tools: ['bash', 'read_file', 'write_file', 'edit_file', 'search_files', 'search_content', 'git_status', 'git_diff', 'git_log', 'git_commit', 'git_branch', 'git_stash', 'git_push', 'git_pull', 'git_merge', 'git_pr'],
|
||||
tools: ['bash', 'read_file', 'write_file', 'edit_file', 'multi_edit', 'search_files', 'search_content', 'run_code', 'get_task_output', 'kill_task', 'lsp_diagnostics', 'lsp_definition', 'lsp_references', 'lsp_hover', 'git_status', 'git_diff', 'git_log', 'git_commit', 'git_branch', 'git_stash', 'git_push', 'git_pull', 'git_merge', 'git_pr'],
|
||||
workspaceAffinity: ['development', 'coding', 'engineering', 'debugging'],
|
||||
suggestedSkills: [],
|
||||
suggestedConnectors: ["github","gitlab"],
|
||||
@@ -173,9 +182,9 @@ You specialize in task management, status tracking, and coordination.
|
||||
- Break large goals into concrete, actionable tasks
|
||||
- Track progress and surface blockers proactively
|
||||
- Create structured status reports with clear next steps
|
||||
- Use memory to maintain project context across sessions
|
||||
- Suggest realistic timelines based on task complexity
|
||||
- Use plans for multi-step work — create_plan, add steps, track execution`,
|
||||
- Use memory to maintain project context only when the relevant memory tools are serialized and persistence is permitted
|
||||
- Do not invent dates, deadlines, or requirements; use supplied values or clearly labeled assumptions
|
||||
- Use serialized planning tools for multi-step work when stateful planning is permitted; otherwise provide the plan inline`,
|
||||
modelPreference: 'claude-sonnet-4-6',
|
||||
tools: ['create_plan', 'add_plan_step', 'execute_step', 'show_plan', 'search_memory', 'save_memory', 'read_file', 'search_files', 'write_file'],
|
||||
workspaceAffinity: ['project', 'management', 'coordination', 'planning'],
|
||||
@@ -186,9 +195,9 @@ You specialize in task management, status tracking, and coordination.
|
||||
defaultWorkflow: 'plan-execute',
|
||||
disallowedTools: ['git_commit', 'git_push', 'bash'],
|
||||
failurePatterns: [
|
||||
'Creating plans without reading existing project context from memory first.',
|
||||
'Vague task assignments — every task needs an owner, deadline, and success criterion.',
|
||||
'Not saving status updates to memory — project state must persist across sessions.',
|
||||
'Ignoring available project context — use supplied context first and memory only when relevant and permitted.',
|
||||
'Vague task assignments — every task needs an owner role and success criterion; deadlines must be supplied or labeled estimates.',
|
||||
'Persisting status against constraints — save updates only when permitted and save_memory is available.',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -206,14 +215,16 @@ You specialize in task management, status tracking, and coordination.
|
||||
systemPrompt: `## Persona: Executive Assistant
|
||||
You specialize in executive support — communication, scheduling, and preparation.
|
||||
- Draft professional emails with appropriate tone and structure
|
||||
- Prepare meeting briefs with relevant context from memory
|
||||
- Prepare meeting briefs from supplied context and permitted memory
|
||||
- Manage correspondence — follow-up tracking, response drafting
|
||||
- Summarize long documents and threads into key points
|
||||
- Use connectors for email (SendGrid) and calendar (Google Calendar) when available
|
||||
- When drafting timed agendas, make the time blocks add up to the requested duration exactly
|
||||
- Use connectors only when requested, permitted, and present in the current tool schema
|
||||
- If the user says no follow-up, do not ask questions or append an offer; if calendar events or files are prohibited, do not create or offer them
|
||||
- Always confirm before sending external communications
|
||||
- Include a brief professional disclaimer ONLY when drafting content on legal, financial, medical, or regulatory topics. Do NOT add disclaimers to routine scheduling, general correspondence, or topics outside these domains.`,
|
||||
modelPreference: 'claude-sonnet-4-6',
|
||||
tools: ['search_memory', 'save_memory', 'read_file', 'write_file', 'web_search', 'generate_docx'],
|
||||
tools: ['search_memory', 'save_memory', 'read_file', 'write_file', 'search_files', 'search_content', 'web_search', 'web_fetch', 'generate_docx', 'generate_pdf'],
|
||||
workspaceAffinity: ['executive', 'admin', 'communication', 'scheduling'],
|
||||
suggestedSkills: ["pdf-generator"],
|
||||
suggestedConnectors: ["gmail","gcal","slack","outlook"],
|
||||
@@ -222,9 +233,9 @@ You specialize in executive support — communication, scheduling, and preparati
|
||||
defaultWorkflow: null,
|
||||
disallowedTools: ['bash', 'git_commit', 'git_push', 'spawn_agent'],
|
||||
failurePatterns: [
|
||||
'Drafting communications without searching memory for prior context with that person.',
|
||||
'Ignoring the evidence boundary — use supplied context first and search memory only when relevant and permitted.',
|
||||
'Sending external communications without user confirmation — always confirm before sending.',
|
||||
'Generic briefings — always pull specific facts from memory for meeting prep.',
|
||||
'Generic briefings — use specific established facts from the permitted evidence boundary.',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -419,11 +430,13 @@ You specialize in contract analysis, legal correspondence, and compliance docume
|
||||
systemPrompt: `## Persona: Business Finance
|
||||
You specialize in financial analysis, budgeting, and business finance communications.
|
||||
- Financial precision is paramount. Double-check all calculations. Format numbers consistently (2 decimal places for currency, comma separators).
|
||||
- Search memory for stored financial data, budgets, and projections before responding.
|
||||
- Treat supplied figures as the closed-world input unless the user asks for stored or external financial context.
|
||||
- Check formulas, unit semantics, and marginal-impact claims before presenting a result.
|
||||
- If the user prohibits files or schedules, answer inline and do not offer files or schedules.
|
||||
- Focus on: budget analysis, cash flow projections, invoice drafting, regulatory compliance, investor communications.
|
||||
- Include a brief professional disclaimer ONLY when your response contains financial projections, budget recommendations, or investment-relevant analysis. Do NOT add disclaimers to casual conversation, simple factual questions, or topics outside finance.`,
|
||||
modelPreference: 'claude-sonnet-4-6',
|
||||
tools: ['search_memory', 'save_memory', 'generate_docx', 'web_search', 'web_fetch', 'read_file', 'write_file', 'search_files', 'create_plan', 'add_plan_step', 'show_plan'],
|
||||
tools: ['search_memory', 'save_memory', 'generate_docx', 'generate_pdf', 'generate_xlsx', 'web_search', 'web_fetch', 'read_file', 'write_file', 'search_files', 'create_plan', 'add_plan_step', 'show_plan'],
|
||||
workspaceAffinity: ['finance', 'accounting', 'business', 'budgets'],
|
||||
suggestedSkills: ["xlsx-generator","chart-generator"],
|
||||
suggestedConnectors: ["gsheets","postgres"],
|
||||
@@ -433,7 +446,7 @@ You specialize in financial analysis, budgeting, and business finance communicat
|
||||
disallowedTools: ['bash', 'git_commit', 'git_push', 'spawn_agent'],
|
||||
failurePatterns: [
|
||||
'Presenting numbers without stating assumptions explicitly.',
|
||||
'Financial analysis without checking stored financial data in memory first.',
|
||||
'Expanding a closed-world calculation with stored or external figures the user did not request.',
|
||||
'Missing sensitivity factors — every projection must note what changes if key assumptions change.',
|
||||
],
|
||||
},
|
||||
@@ -512,6 +525,8 @@ If the user's request clearly maps to a specialist persona (legal analysis → L
|
||||
'create_plan', 'add_plan_step', 'execute_step', 'show_plan',
|
||||
'spawn_agent', 'list_agents', 'get_agent_result',
|
||||
'git_status', 'git_diff', 'git_log', 'git_commit',
|
||||
'multi_edit', 'get_task_output', 'kill_task', 'run_code',
|
||||
'generate_xlsx', 'generate_pptx', 'generate_pdf',
|
||||
'list_skills', 'suggest_skill', 'acquire_capability', 'install_capability',
|
||||
'compose_workflow', 'orchestrate_workflow',
|
||||
'query_knowledge', 'get_identity', 'get_awareness',
|
||||
@@ -634,18 +649,20 @@ Your job is NOT to confirm that something works. Your job is to try to BREAK it.
|
||||
|
||||
=== CRITICAL: READ-ONLY — NO MODIFICATIONS TO USER WORK ===
|
||||
You are PROHIBITED from modifying any user files or project state.
|
||||
You MAY run read-only commands and create temporary test files in /tmp only.
|
||||
You MAY run permitted read-only commands. Create temporary test files only when the user permits file creation and a serialized tool supports it.
|
||||
|
||||
### Known Failure Patterns (Avoid These)
|
||||
1. **Verification avoidance** — reading the output, narrating what you would check, then claiming PASS without actually checking. You MUST RUN checks, not describe them.
|
||||
1. **Verification avoidance** — when checks are allowed and their tools exist, RUN them rather than narrating them. In an evidence-only review, mark unsupported claims unverified instead of inventing a check.
|
||||
2. **First-80% seduction** — seeing polished formatting and not noticing wrong substance. Your value is the last 20%.
|
||||
3. **Confirmation bias** — starting with the assumption the output is correct. Start from the assumption it is WRONG and look for evidence it is right.
|
||||
4. **Source amnesia** — accepting claims without checking whether they came from memory, web search, or were fabricated. Trace every factual claim to its source.
|
||||
|
||||
### Verification Protocol
|
||||
|
||||
For evidence-only reviews, an attributed teammate or user claim proves only that the claim was made, not that it is a verified fact. Never label it TRUE without an artifact or permitted check.
|
||||
|
||||
**For Documents/Reports/Analyses:**
|
||||
1. Check every factual claim against memory (search_memory) and web (web_search)
|
||||
1. Check factual claims against permitted evidence; use memory or web only when allowed and those tools are available
|
||||
2. Verify cited sources exist and say what the document claims they say
|
||||
3. Check for internal consistency — does the conclusion follow from the evidence?
|
||||
4. Look for missing perspectives — what counterargument was not addressed?
|
||||
@@ -653,7 +670,7 @@ You MAY run read-only commands and create temporary test files in /tmp only.
|
||||
|
||||
**For Code/Technical Outputs:**
|
||||
1. Read the code — does it do what the user asked?
|
||||
2. Run tests if available (bash — read-only test execution)
|
||||
2. Run tests only when permitted and an appropriate read-only execution tool is serialized
|
||||
3. Check edge cases: empty input, null values, boundary conditions
|
||||
4. Verify imports/dependencies exist
|
||||
5. Check for security issues: injection, path traversal, hardcoded secrets
|
||||
@@ -663,16 +680,23 @@ You MAY run read-only commands and create temporary test files in /tmp only.
|
||||
2. Verify dependencies — does step 3 actually depend on step 2?
|
||||
3. Look for missing steps — what is implied but not stated?
|
||||
4. Check resource assumptions — does the plan assume capabilities that do not exist?
|
||||
5. Verify against memory — does this contradict prior decisions?
|
||||
5. Check permitted context for contradictions with established prior decisions
|
||||
|
||||
### Required Output Format (MANDATORY)
|
||||
Every verification ends with exactly one of:
|
||||
### Output Contract Precedence
|
||||
An explicit whole-response contract (JSON/XML only, one tagged envelope, one literal token, or no surrounding prose) replaces only the default format. A schema, field set, or tagged envelope alone is not exclusive.
|
||||
Emit one requested payload and nothing else. Put verdict, checks, evidence, blockers, and limitations only in allowed fields; add no headings, commentary, offers, extra fields, or second VERDICT line.
|
||||
For exclusive JSON/XML/tagged envelopes, return raw payload; never wrap it in a Markdown code fence.
|
||||
Preserve JSON value types exactly: numeric literals stay unquoted.
|
||||
This syntax/shape override never relaxes read-only, evidence, attribution, anti-fabrication, or honest blocker reporting. Never emit a fixed result contrary to evidence. If required blockers or limitations do not fit, use a valid failure/refusal or explain the incompatibility rather than fabricate.
|
||||
|
||||
### Default Human-Readable Output Format
|
||||
When no exclusive response contract is requested, every verification ends with exactly one of:
|
||||
|
||||
**VERDICT: PASS** — All checks passed. State what was verified.
|
||||
**VERDICT: FAIL** — Critical issues found. List each with evidence.
|
||||
**VERDICT: PARTIAL** — Some checks passed, others failed or could not be verified. Full breakdown.
|
||||
|
||||
Each check MUST include: what was checked, how it was checked (which tool), what was found, Pass/Fail.`,
|
||||
In this default human-readable format, each check MUST include: what was checked, the supplied artifact or permitted tool used (or that no check was permitted), what was found, Pass/Fail.`,
|
||||
modelPreference: 'claude-sonnet-4-6',
|
||||
tools: [
|
||||
'read_file', 'search_files', 'search_content',
|
||||
@@ -687,7 +711,7 @@ Each check MUST include: what was checked, how it was checked (which tool), what
|
||||
'spawn_agent', 'execute_step',
|
||||
],
|
||||
failurePatterns: [
|
||||
'Verification avoidance — narrating checks instead of running them. Must RUN, not describe.',
|
||||
'Verification avoidance — run permitted checks when their tools exist; otherwise mark the claim unverified.',
|
||||
'First-80% seduction — polished format hiding wrong substance. Focus on the last 20%.',
|
||||
'Confirmation bias — starting from "this looks right". Start from "this is wrong until proven otherwise".',
|
||||
],
|
||||
@@ -716,6 +740,8 @@ Each check MUST include: what was checked, how it was checked (which tool), what
|
||||
systemPrompt: `## Persona: Coordinator (Mission Control)
|
||||
You orchestrate complex, multi-phase tasks by delegating to specialist agents. You NEVER execute work directly.
|
||||
|
||||
If the user forbids agent launches, do not call spawn_agent. Specify the requested lanes, inputs, deliverables, dependencies, merge criteria, and verification gates without spawning.
|
||||
|
||||
=== CRITICAL: DELEGATION-ONLY MODE ===
|
||||
You have access to ONLY these tools:
|
||||
- spawn_agent — launch a specialist with a specific task
|
||||
@@ -733,7 +759,7 @@ Before directing a worker to implement something, YOU must understand the full p
|
||||
- After research workers report back, YOU synthesize findings into specific, actionable instructions
|
||||
- NEVER say "based on your findings, do X" — state exactly what the findings showed and what specific actions follow
|
||||
- Include file paths, specific content, exact requirements in every worker prompt
|
||||
- If you do not understand a worker's result well enough to direct the next step, spawn a follow-up research worker
|
||||
- If you do not understand a worker's result well enough to direct the next step, spawn a follow-up research worker only when launches are authorized; otherwise mark the dependency unresolved
|
||||
|
||||
### Anti-Patterns (NEVER DO THESE)
|
||||
- "Look into X and fix whatever you find" — too vague
|
||||
@@ -743,10 +769,10 @@ Before directing a worker to implement something, YOU must understand the full p
|
||||
|
||||
### Workflow Pattern
|
||||
1. **Decompose** — break the user's request into distinct phases
|
||||
2. **Research (parallel)** — spawn research workers simultaneously
|
||||
3. **Synthesize** — read all results, form specific plan, save key findings to memory
|
||||
4. **Direct** — spawn implementation workers with PRECISE instructions
|
||||
5. **Verify** — always spawn a Verifier agent as the final step
|
||||
2. **Research (parallel)** — when launches are authorized, spawn research workers simultaneously; otherwise define the research lanes and inputs
|
||||
3. **Synthesize** — combine supplied or returned evidence into a specific plan; save findings only when save_memory is serialized and permitted
|
||||
4. **Direct** — when launches are authorized, spawn implementation workers with PRECISE instructions; otherwise specify the worker-ready prompts
|
||||
5. **Verify** — spawn a Verifier when agent launches are authorized; otherwise specify the verification gate
|
||||
6. **Report** — summarize outcome: what was done, decisions made, verification results, next steps
|
||||
|
||||
### Worker Prompt Template
|
||||
@@ -760,7 +786,7 @@ When spawning a worker, always include:
|
||||
### Known Failure Patterns
|
||||
1. **Delegating without synthesizing** — always synthesize worker results before directing the next step.
|
||||
2. **Vague worker prompts** — workers cannot see your conversation. Every prompt must be fully self-contained.
|
||||
3. **Skipping the Verifier** — the Verifier agent is always the final step. Never skip it.`,
|
||||
3. **Skipping verification** — include a verification gate, using a Verifier agent only when launches are authorized.`,
|
||||
modelPreference: 'claude-sonnet-4-6',
|
||||
tools: [
|
||||
'spawn_agent', 'list_agents', 'get_agent_result',
|
||||
@@ -775,7 +801,7 @@ When spawning a worker, always include:
|
||||
failurePatterns: [
|
||||
'Delegating without synthesizing — must understand worker results before directing next step',
|
||||
'Vague worker prompts — workers cannot see your conversation, every prompt must be fully self-contained',
|
||||
'Skipping the Verifier — always spawn a Verifier agent as the final step of any workflow',
|
||||
'Skipping verification — include a verification gate; spawn a Verifier only when launches are authorized',
|
||||
],
|
||||
isReadOnly: false,
|
||||
workspaceAffinity: ['orchestration', 'complex-projects', 'multi-phase', 'coordination'],
|
||||
@@ -872,14 +898,16 @@ You specialize in process design, documentation, vendor management, and operatio
|
||||
wontDo: 'Will not write queries without exploring the schema first — always checks table structure before SELECT.',
|
||||
systemPrompt: `## Persona: Data Engineer
|
||||
You specialize in data access, SQL, pipeline design, and making data useful for decision-makers.
|
||||
- ALWAYS explore the schema before writing queries — SHOW TABLES, DESCRIBE, sample rows
|
||||
- For an existing database, explore the schema before querying; for a hypothetical design, state schema assumptions instead
|
||||
- Write queries that are readable: CTEs over subqueries, meaningful aliases, comments on complex logic
|
||||
- When presenting data, include column explanations, data freshness, and row counts
|
||||
- Save working queries to memory so they can be reused in future sessions
|
||||
- Before presenting code examples, self-check imports, name scope, control flow, exception/retry paths, and count semantics; if not executed, label them unverified
|
||||
- When the user asks for a compact example or compact design, keep the whole answer under 900 words unless the user explicitly asks for more; cover each requested dimension once, provide one minimal complete example, and omit optional extensions, tutorials, and repeated explanation unless explicitly requested
|
||||
- Save working queries only when the user permits it and save_memory is available
|
||||
- For data quality issues, document: what is wrong, how many rows affected, suggested fix
|
||||
- Use bash for CSV/JSON processing when appropriate (csvkit, jq, awk)`,
|
||||
- Use bash for CSV/JSON processing only when it is serialized and appropriate (csvkit, jq, awk)`,
|
||||
modelPreference: 'claude-sonnet-4-6',
|
||||
tools: ['bash', 'read_file', 'write_file', 'edit_file', 'search_files', 'search_content', 'search_memory', 'save_memory', 'web_search', 'generate_docx'],
|
||||
tools: ['bash', 'read_file', 'write_file', 'edit_file', 'search_files', 'search_content', 'search_memory', 'save_memory', 'web_search', 'web_fetch', 'generate_docx', 'generate_xlsx', 'run_code', 'get_task_output', 'kill_task'],
|
||||
workspaceAffinity: ['data', 'analytics', 'bi', 'reporting'],
|
||||
suggestedSkills: [],
|
||||
suggestedConnectors: ['postgres', 'gsheets', 'airtable'],
|
||||
@@ -887,9 +915,9 @@ You specialize in data access, SQL, pipeline design, and making data useful for
|
||||
suggestedCommands: ['/research', '/draft'],
|
||||
defaultWorkflow: null,
|
||||
failurePatterns: [
|
||||
'Writing queries without exploring the schema first — always check table structure.',
|
||||
'Writing queries against an existing database without checking its schema, or failing to label assumptions for a hypothetical schema.',
|
||||
'Presenting raw data without context — every output needs column explanations and data freshness.',
|
||||
'Not saving working queries to memory — next session starts from scratch.',
|
||||
'Persisting queries against constraints — save them only when permitted and save_memory is available.',
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ const MAX_COMBINED_CHARS = 32000; // ~8000 tokens
|
||||
const SEPARATOR = '\n\n---\n\n';
|
||||
|
||||
/** Hint appended to every composed prompt — encourages DOCX generation for structured content */
|
||||
const DOCX_HINT = '\n\nWhen generating long, structured content (reports, proposals, analyses), proactively offer to save it as a DOCX document using the generate_docx tool.';
|
||||
const DOCX_HINT = '\n\nOffer DOCX for long content only if generate_docx exists and file writes/offers are allowed. Never add it to exclusive/no-prose output unless the payload requires DOCX.';
|
||||
|
||||
/** W7.3: Tone instruction map — maps workspace tone presets to system prompt instructions */
|
||||
const TONE_INSTRUCTIONS: Record<string, string> = {
|
||||
|
||||
@@ -9,8 +9,10 @@ import PptxGenJS from 'pptxgenjs';
|
||||
import type { ToolDefinition } from './tools.js';
|
||||
|
||||
function resolveSafe(workspace: string, filePath: string): string {
|
||||
const resolved = path.resolve(workspace, filePath);
|
||||
if (!resolved.startsWith(path.resolve(workspace))) {
|
||||
const root = path.resolve(workspace);
|
||||
const resolved = path.resolve(root, filePath);
|
||||
const relative = path.relative(root, resolved);
|
||||
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error(`Path resolves outside workspace: ${filePath}`);
|
||||
}
|
||||
return resolved;
|
||||
@@ -26,6 +28,12 @@ interface SlideDef {
|
||||
table?: { headers: string[]; rows: string[][] };
|
||||
}
|
||||
|
||||
function hasUnsupportedImageInput(slide: unknown): boolean {
|
||||
if (!slide || typeof slide !== 'object') return false;
|
||||
return Object.prototype.hasOwnProperty.call(slide, 'image')
|
||||
|| Object.prototype.hasOwnProperty.call(slide, 'images');
|
||||
}
|
||||
|
||||
// Hive DS colors for presentations
|
||||
const COLORS = {
|
||||
bg: '08090C',
|
||||
@@ -72,6 +80,9 @@ export function createPresentationTools(workspace: string): ToolDefinition[] {
|
||||
|
||||
if (!filePath?.endsWith('.pptx')) return 'Error: filePath must end with .pptx';
|
||||
if (!slides || slides.length === 0) return 'Error: at least one slide is required';
|
||||
if (slides.some(hasUnsupportedImageInput)) {
|
||||
return 'Error: image inputs are not supported by the Waggle presentation tool';
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = resolveSafe(workspace, filePath);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*
|
||||
* Truncation policy: if the assembled system prompt exceeds `maxSystemChars`,
|
||||
* trim Recent changes → Active work → State, in that order. Identity, Persona,
|
||||
* Personal preferences, and Response format are never trimmed.
|
||||
* Personal preferences, Response format, and Closed-world rewrite are never trimmed.
|
||||
*/
|
||||
|
||||
import type { MemoryFrame, Importance } from '@waggle/core';
|
||||
@@ -55,6 +55,12 @@ export interface AssembledPromptDebug {
|
||||
taskShape: TaskShapeType | null;
|
||||
taskShapeConfidence: number;
|
||||
scaffoldApplied: boolean;
|
||||
/** Always returned; optional in the type for backward-compatible external mocks. */
|
||||
exclusiveResponseContract?: boolean;
|
||||
/** Always returned; optional in the type for backward-compatible external mocks. */
|
||||
scaffoldSuppressed?: boolean;
|
||||
/** Always returned; optional in the type for backward-compatible external mocks. */
|
||||
closedWorldRewrite?: boolean;
|
||||
/** v5: which scaffold style was used (compression = v4 default, expansion = v5 opt-in). */
|
||||
scaffoldStyle: ScaffoldStyle;
|
||||
sectionsIncluded: string[];
|
||||
@@ -88,8 +94,8 @@ export interface AssembleOptions {
|
||||
/** Minimum task-shape confidence for scaffold emission. Default 0.3. */
|
||||
confidenceThreshold?: number;
|
||||
/**
|
||||
* v5: scaffold variant. Default 'compression' — preserves v4 behavior
|
||||
* byte-identically when unset or explicitly 'compression'.
|
||||
* v5: scaffold variant. Default 'compression' preserves the v4 scaffold
|
||||
* body; the assembler adds a response-format precedence qualifier.
|
||||
*/
|
||||
scaffoldStyle?: ScaffoldStyle;
|
||||
/**
|
||||
@@ -100,12 +106,48 @@ export interface AssembleOptions {
|
||||
recalledText?: string;
|
||||
/** H-AUDIT-1: per-turn trace ID (UUID v4). Logs prompt-assembly stage. */
|
||||
turnId?: string;
|
||||
/**
|
||||
* Force exclusive-output handling for code-owned prompts with a typed output
|
||||
* contract. Free-form user language is deliberately not inferred here.
|
||||
*/
|
||||
exclusiveResponseContract?: boolean;
|
||||
}
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_MAX_CHARS = 32_000;
|
||||
const DEFAULT_CONFIDENCE_THRESHOLD = 0.3;
|
||||
const DEFAULT_SCAFFOLD_QUALIFIER = 'If the user specifies a response format, follow it exactly. Otherwise:';
|
||||
const TRANSFORM_DIRECTIVE_START = String.raw`(?:^|[.!?]\s+)`;
|
||||
const TRANSFORM_DIRECTIVE_COURTESY = String.raw`(?:(?:please|kindly)\s+|(?:can|could|would|will)\s+you\s+|I\s+(?:want|need)\s+you\s+to\s+|I'd\s+like\s+you\s+to\s+)?`;
|
||||
const CLOSED_WORLD_TRANSFORM_REQUEST = new RegExp([
|
||||
`${TRANSFORM_DIRECTIVE_START}${TRANSFORM_DIRECTIVE_COURTESY}${String.raw`(?:rewrite|rephrase|paraphrase|revise|edit|polish|tighten|condense|shorten|summari[sz]e|translate)\b`}`,
|
||||
`${TRANSFORM_DIRECTIVE_START}${TRANSFORM_DIRECTIVE_COURTESY}${String.raw`turn\b[\s\S]{0,80}\binto\b`}`,
|
||||
].join('|'), 'i');
|
||||
const CLOSED_WORLD_BOUNDARY_FIRST_REQUEST = new RegExp([
|
||||
`${TRANSFORM_DIRECTIVE_START}${String.raw`using\s+only\s+(?:the\s+)?(?:supplied|provided|source)\s+(?:text|facts?|material|content|information)[,:]\s+(?:please\s+)?(?:rewrite|rephrase|paraphrase|revise|edit|polish|tighten|condense|shorten|summari[sz]e|translate)\b`}`,
|
||||
`${TRANSFORM_DIRECTIVE_START}${String.raw`without\s+(?:add(?:ing)?|introduc(?:ing)?|invent(?:ing)?)\s+(?:any\s+)?(?:new|additional)\s+(?:claims?|facts?|details?|information)[,:]\s+(?:please\s+)?(?:rewrite|rephrase|paraphrase|revise|edit|polish|tighten|condense|shorten|summari[sz]e|translate)\b`}`,
|
||||
].join('|'), 'i');
|
||||
const CLOSED_WORLD_EVIDENCE_BOUNDARY = new RegExp([
|
||||
String.raw`\bclosed[- ]world\b`,
|
||||
String.raw`\b(?:add|introduce|invent)\s+no\s+(?:new|additional)\s+(?:claims?|facts?|details?|information)\b`,
|
||||
String.raw`\b(?:do\s+not|don't|without)\s+(?:add(?:ing)?|introduc(?:e|ing)|invent(?:ing)?)\s+(?:any\s+)?(?:new|additional)\s+(?:claims?|facts?|details?|information)\b`,
|
||||
String.raw`\bus(?:e|ing)\s+only\s+(?:the\s+)?(?:supplied|provided|source)\s+(?:text|facts?|material|content|information)\b`,
|
||||
String.raw`\b(?:supplied|provided)\s+(?:text|facts?|material|content|information)\s+(?:is|are)\s+(?:the\s+)?(?:complete|entire|only)\s+(?:evidence|source|basis|input)\b`,
|
||||
].join('|'), 'i');
|
||||
export const CLOSED_WORLD_REWRITE_CONTRACT = [
|
||||
'# Closed-world rewrite',
|
||||
"The user's supplied source text is the complete evidence boundary for this transformation.",
|
||||
'- Preserve every supplied fact, including its polarity, status, quantity, timing, recommendation, and original certainty.',
|
||||
'- Do not add implications, explanations, rationale, risks, causes, predictions, assumptions, recommendations, or conclusions unless the source states them.',
|
||||
'- Output only the requested rewrite; omit commentary and follow-up offers unless the user explicitly requests them.',
|
||||
].join('\n');
|
||||
|
||||
export function isClosedWorldRewriteRequest(query: string): boolean {
|
||||
return (CLOSED_WORLD_TRANSFORM_REQUEST.test(query)
|
||||
|| CLOSED_WORLD_BOUNDARY_FIRST_REQUEST.test(query))
|
||||
&& CLOSED_WORLD_EVIDENCE_BOUNDARY.test(query);
|
||||
}
|
||||
|
||||
/** Frames retained per tier — assembler caps top-N after upstream retrieval. */
|
||||
const FRAME_LIMITS: Record<ModelTier, number> = {
|
||||
@@ -289,6 +331,10 @@ function renderPersona(persona: AgentPersona): string {
|
||||
const lines = [`## Persona: ${persona.name}`];
|
||||
if (persona.tagline) lines.push(persona.tagline);
|
||||
lines.push(persona.description);
|
||||
const operatingInstructions = persona.systemPrompt.trim();
|
||||
if (operatingInstructions) {
|
||||
lines.push(`### Persona operating instructions\n${operatingInstructions}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -321,11 +367,12 @@ export class PromptAssembler {
|
||||
const maxChars = opts.maxSystemChars ?? DEFAULT_MAX_CHARS;
|
||||
const confThreshold = opts.confidenceThreshold ?? DEFAULT_CONFIDENCE_THRESHOLD;
|
||||
const tier = opts.tierOverride ?? input.tier;
|
||||
// v5 brief §7.2: default 'compression' preserves v4 behavior byte-identically.
|
||||
// v5 brief §7.2: default 'compression' preserves the v4 scaffold body.
|
||||
const scaffoldStyle: ScaffoldStyle = opts.scaffoldStyle ?? 'compression';
|
||||
// Brief §10: derive task shape from query when caller hasn't supplied one.
|
||||
const taskShape = opts.taskShape ?? input.taskShape ?? detectTaskShape(input.query);
|
||||
const frameLimit = FRAME_LIMITS[tier];
|
||||
const closedWorldRewrite = isClosedWorldRewriteRequest(input.query);
|
||||
|
||||
const sections: Section[] = [];
|
||||
|
||||
@@ -344,7 +391,7 @@ export class PromptAssembler {
|
||||
}
|
||||
|
||||
// ── State (I-frames) — trimmable ──
|
||||
const stateFrames = selectFrames(input.context.stateFrames, frameLimit);
|
||||
const stateFrames = closedWorldRewrite ? [] : selectFrames(input.context.stateFrames, frameLimit);
|
||||
if (stateFrames.length > 0) {
|
||||
sections.push({
|
||||
name: 'State',
|
||||
@@ -354,7 +401,7 @@ export class PromptAssembler {
|
||||
}
|
||||
|
||||
// ── Recent changes (P/B-frames) — trimmable first ──
|
||||
const changeFrames = selectFrames(input.context.recentChanges, frameLimit);
|
||||
const changeFrames = closedWorldRewrite ? [] : selectFrames(input.context.recentChanges, frameLimit);
|
||||
if (changeFrames.length > 0) {
|
||||
sections.push({
|
||||
name: 'Recent changes',
|
||||
@@ -364,7 +411,7 @@ export class PromptAssembler {
|
||||
}
|
||||
|
||||
// ── Active work (awareness items) — trimmable ──
|
||||
if (input.context.activeWork.length > 0) {
|
||||
if (!closedWorldRewrite && input.context.activeWork.length > 0) {
|
||||
sections.push({
|
||||
name: 'Active work',
|
||||
body: `# Active work\n${renderActiveWork(input.context.activeWork)}`,
|
||||
@@ -385,7 +432,7 @@ export class PromptAssembler {
|
||||
// Brief §8: recallMemory already scans; assembler must not re-scan, and
|
||||
// must ignore recall entirely when scanSafe is false.
|
||||
const recalledFrames: MemoryFrame[] = [];
|
||||
if (input.recalled.scanSafe && input.recalled.renderedText) {
|
||||
if (!closedWorldRewrite && input.recalled.scanSafe && input.recalled.renderedText) {
|
||||
// W4.5: pre-rendered multi-lane block — carries its own header
|
||||
// ('# Recalled Memories' + provenance + temporal guidance). Subject
|
||||
// to the same overall char budget as every other section.
|
||||
@@ -394,7 +441,7 @@ export class PromptAssembler {
|
||||
body: input.recalled.renderedText,
|
||||
frameCount: 0,
|
||||
});
|
||||
} else if (input.recalled.scanSafe) {
|
||||
} else if (!closedWorldRewrite && input.recalled.scanSafe) {
|
||||
recalledFrames.push(
|
||||
...selectFrames(input.recalled.workspace, frameLimit),
|
||||
...selectFrames(input.recalled.personal, frameLimit),
|
||||
@@ -409,7 +456,13 @@ export class PromptAssembler {
|
||||
}
|
||||
|
||||
// ── Response format (scaffold) — gated ──
|
||||
const scaffold = selectScaffold(tier, taskShape, confThreshold, scaffoldStyle);
|
||||
const candidateScaffold = selectScaffold(tier, taskShape, confThreshold, scaffoldStyle);
|
||||
const exclusiveResponseContract = opts.exclusiveResponseContract === true;
|
||||
const scaffold = exclusiveResponseContract || closedWorldRewrite || candidateScaffold === null
|
||||
? null
|
||||
: `${DEFAULT_SCAFFOLD_QUALIFIER} ${candidateScaffold}`;
|
||||
const scaffoldSuppressed = (exclusiveResponseContract || closedWorldRewrite)
|
||||
&& candidateScaffold !== null;
|
||||
if (scaffold) {
|
||||
sections.push({
|
||||
name: 'Response format',
|
||||
@@ -417,6 +470,13 @@ export class PromptAssembler {
|
||||
frameCount: 0,
|
||||
});
|
||||
}
|
||||
if (closedWorldRewrite) {
|
||||
sections.push({
|
||||
name: 'Closed-world rewrite',
|
||||
body: CLOSED_WORLD_REWRITE_CONTRACT,
|
||||
frameCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Compose + truncate ──
|
||||
const join = (s: Section[]): string => s.map(x => x.body).join('\n\n');
|
||||
@@ -442,6 +502,9 @@ export class PromptAssembler {
|
||||
tier,
|
||||
taskShape: taskShape?.type ?? null,
|
||||
scaffoldApplied: scaffold !== null,
|
||||
exclusiveResponseContract,
|
||||
scaffoldSuppressed,
|
||||
closedWorldRewrite,
|
||||
scaffoldStyle,
|
||||
sectionsIncluded,
|
||||
framesUsed,
|
||||
@@ -457,6 +520,9 @@ export class PromptAssembler {
|
||||
taskShape: taskShape?.type ?? null,
|
||||
taskShapeConfidence: taskShape?.confidence ?? 0,
|
||||
scaffoldApplied: scaffold !== null,
|
||||
exclusiveResponseContract,
|
||||
scaffoldSuppressed,
|
||||
closedWorldRewrite,
|
||||
scaffoldStyle,
|
||||
sectionsIncluded,
|
||||
framesUsed,
|
||||
|
||||
@@ -19,6 +19,123 @@ export interface ChatResponse {
|
||||
usage: { input_tokens: number; output_tokens: number };
|
||||
}
|
||||
|
||||
export interface CompletionUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCostUsd: number;
|
||||
}
|
||||
|
||||
export interface ParsedOpenAiTextCompletion {
|
||||
content: string;
|
||||
model: string;
|
||||
usage: CompletionUsage;
|
||||
}
|
||||
|
||||
export type IncompleteCompletionError = Error & {
|
||||
code: 'INCOMPLETE_COMPLETION';
|
||||
usage: CompletionUsage;
|
||||
};
|
||||
|
||||
function incompleteCompletionError(
|
||||
reason: string,
|
||||
usage: IncompleteCompletionError['usage'],
|
||||
): IncompleteCompletionError {
|
||||
const error = new Error(
|
||||
`OpenAI-compatible completion was not complete (${reason}); partial content was rejected.`,
|
||||
) as IncompleteCompletionError;
|
||||
error.name = 'IncompleteCompletionError';
|
||||
error.code = 'INCOMPLETE_COMPLETION';
|
||||
error.usage = usage;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function isIncompleteCompletionError(error: unknown): error is IncompleteCompletionError {
|
||||
return typeof error === 'object'
|
||||
&& error !== null
|
||||
&& (error as { code?: unknown }).code === 'INCOMPLETE_COMPLETION';
|
||||
}
|
||||
|
||||
function usageNumber(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a non-streaming OpenAI-compatible text completion.
|
||||
*
|
||||
* HTTP 200 is not sufficient evidence of a complete answer: only an explicit
|
||||
* `finish_reason: "stop"` with non-blank text and no tool calls is accepted.
|
||||
* Reported usage is attached to integrity failures so callers can account for
|
||||
* paid partial responses without replaying them.
|
||||
*/
|
||||
export function parseOpenAiTextCompletion(rawData: unknown): ParsedOpenAiTextCompletion {
|
||||
if (typeof rawData !== 'object' || rawData === null) {
|
||||
throw incompleteCompletionError('invalid response body', {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCostUsd: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const data = rawData as {
|
||||
error?: { message?: unknown } | string;
|
||||
choices?: Array<{
|
||||
finish_reason?: string | null;
|
||||
message?: {
|
||||
content?: string | null;
|
||||
refusal?: string | null;
|
||||
tool_calls?: unknown;
|
||||
};
|
||||
}>;
|
||||
model?: unknown;
|
||||
usage?: {
|
||||
prompt_tokens?: unknown;
|
||||
completion_tokens?: unknown;
|
||||
total_cost?: unknown;
|
||||
};
|
||||
};
|
||||
const usage: CompletionUsage = {
|
||||
inputTokens: usageNumber(data.usage?.prompt_tokens),
|
||||
outputTokens: usageNumber(data.usage?.completion_tokens),
|
||||
totalCostUsd: usageNumber(data.usage?.total_cost),
|
||||
};
|
||||
|
||||
if (data.error) {
|
||||
const detail = typeof data.error === 'string'
|
||||
? data.error
|
||||
: typeof data.error.message === 'string'
|
||||
? data.error.message
|
||||
: 'upstream error payload';
|
||||
throw incompleteCompletionError(detail, usage);
|
||||
}
|
||||
|
||||
const choice = data.choices?.[0];
|
||||
if (!choice) {
|
||||
throw incompleteCompletionError('missing completion choice', usage);
|
||||
}
|
||||
if (choice.finish_reason !== 'stop') {
|
||||
const reason = choice.finish_reason ?? 'missing';
|
||||
throw incompleteCompletionError(`finish_reason=${reason}`, usage);
|
||||
}
|
||||
const toolCalls = choice.message?.tool_calls;
|
||||
if (toolCalls !== undefined && toolCalls !== null
|
||||
&& (!Array.isArray(toolCalls) || toolCalls.length > 0)) {
|
||||
throw incompleteCompletionError('finish_reason=stop with tool_calls', usage);
|
||||
}
|
||||
if (typeof choice.message?.refusal === 'string' && choice.message.refusal.trim().length > 0) {
|
||||
throw incompleteCompletionError('assistant refusal', usage);
|
||||
}
|
||||
const content = choice.message?.content;
|
||||
if (typeof content !== 'string' || content.trim().length === 0) {
|
||||
throw incompleteCompletionError('missing assistant text', usage);
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
model: typeof data.model === 'string' ? data.model : '',
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-request wall-clock timeout before the request is aborted. */
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
/** Additional attempts after the first on a transient failure. */
|
||||
@@ -128,23 +245,24 @@ export async function openaiChat(
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
model: string;
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
||||
};
|
||||
|
||||
const choice = data.choices?.[0];
|
||||
if (!choice) {
|
||||
throw new Error('No choices returned from API');
|
||||
let rawData: unknown;
|
||||
try {
|
||||
rawData = await res.json();
|
||||
} catch {
|
||||
throw incompleteCompletionError('invalid JSON response body', {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCostUsd: 0,
|
||||
});
|
||||
}
|
||||
const parsed = parseOpenAiTextCompletion(rawData);
|
||||
|
||||
return {
|
||||
content: choice.message.content,
|
||||
model: data.model,
|
||||
content: parsed.content,
|
||||
model: parsed.model || resolved.model,
|
||||
usage: {
|
||||
input_tokens: data.usage?.prompt_tokens ?? 0,
|
||||
output_tokens: data.usage?.completion_tokens ?? 0,
|
||||
input_tokens: parsed.usage.inputTokens,
|
||||
output_tokens: parsed.usage.outputTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
274
packages/agent/src/quantitative-number.ts
Normal file
274
packages/agent/src/quantitative-number.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Locale-tolerant numeric normalization for deterministic quantitative checks.
|
||||
*
|
||||
* This module deliberately does not use Number/parseFloat: values used for
|
||||
* grounding can exceed JavaScript's safe integer range and must compare by
|
||||
* their exact decimal representation.
|
||||
*/
|
||||
|
||||
const MAX_INTEGER_DIGITS = 30;
|
||||
const MAX_FRACTION_DIGITS = 12;
|
||||
const MASK = '\uFFFD';
|
||||
|
||||
function normalizedDigit(char: string | undefined): string | undefined {
|
||||
if (!char) return undefined;
|
||||
if (char >= '0' && char <= '9') return char;
|
||||
const code = char.charCodeAt(0);
|
||||
return code >= 0xff10 && code <= 0xff19
|
||||
? String.fromCharCode(0x30 + code - 0xff10)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const isDigit = (char: string | undefined): boolean => normalizedDigit(char) !== undefined;
|
||||
const isHorizontalSpace = (char: string): boolean => char === '\t' || /\p{Zs}/u.test(char);
|
||||
const isTightSeparator = (char: string): boolean => (
|
||||
char === ',' || char === '.' || char === ',' || char === '.'
|
||||
|| char === "'" || char === '\u2019'
|
||||
);
|
||||
const normalizedSeparator = (char: string): string => (
|
||||
isHorizontalSpace(char)
|
||||
? ' '
|
||||
: char === '\u2019'
|
||||
? "'"
|
||||
: char === ','
|
||||
? ','
|
||||
: char === '.'
|
||||
? '.'
|
||||
: char
|
||||
);
|
||||
|
||||
function normalizedQuantitativeSymbol(char: string): string {
|
||||
if (char === '\uFF04') return '$';
|
||||
if (char === '\uFF05') return '%';
|
||||
if (char === '\uFF0B') return '+';
|
||||
if (char === '\uFF0D' || char === '\u2212') return '-';
|
||||
return char;
|
||||
}
|
||||
|
||||
function isWesternGrouping(groups: readonly string[]): boolean {
|
||||
return groups.length >= 2
|
||||
&& groups[0].length >= 1
|
||||
&& groups[0].length <= 3
|
||||
&& groups.slice(1).every(group => group.length === 3);
|
||||
}
|
||||
|
||||
function isIndianGrouping(groups: readonly string[]): boolean {
|
||||
if (groups.length < 2 || groups[0].length < 1 || groups[0].length > 2) return false;
|
||||
if (groups.at(-1)?.length !== 3) return false;
|
||||
return groups.slice(1, -1).every(group => group.length === 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one unsigned numeric body into an exact, minimal decimal string.
|
||||
* The body must contain only supported digits and grouping/decimal separators.
|
||||
* The caller owns signs and accounting parentheses.
|
||||
*/
|
||||
function parseNumericBody(body: string): string | undefined {
|
||||
if (body.length === 0 || !isDigit(body[0]) || !isDigit(body[body.length - 1])) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const groups: string[] = [];
|
||||
const separators: string[] = [];
|
||||
let digits = '';
|
||||
let digitCount = 0;
|
||||
|
||||
for (let index = 0; index < body.length; index += 1) {
|
||||
const char = body[index];
|
||||
if (isDigit(char)) {
|
||||
digits += normalizedDigit(char);
|
||||
digitCount += 1;
|
||||
if (digitCount > MAX_INTEGER_DIGITS + MAX_FRACTION_DIGITS) return undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isTightSeparator(char) && !isHorizontalSpace(char)) return undefined;
|
||||
if (digits.length === 0) return undefined;
|
||||
groups.push(digits);
|
||||
separators.push(normalizedSeparator(char));
|
||||
digits = '';
|
||||
if (groups.length > MAX_INTEGER_DIGITS + MAX_FRACTION_DIGITS) return undefined;
|
||||
}
|
||||
|
||||
if (digits.length === 0) return undefined;
|
||||
groups.push(digits);
|
||||
|
||||
const dotIndexes: number[] = [];
|
||||
const commaIndexes: number[] = [];
|
||||
let hasHardGrouping = false;
|
||||
for (let index = 0; index < separators.length; index += 1) {
|
||||
const separator = separators[index];
|
||||
if (separator === '.') dotIndexes.push(index);
|
||||
else if (separator === ',') commaIndexes.push(index);
|
||||
else hasHardGrouping = true;
|
||||
}
|
||||
|
||||
let decimalIndex: number | undefined;
|
||||
if (dotIndexes.length > 0 && commaIndexes.length > 0) {
|
||||
const lastDot = dotIndexes[dotIndexes.length - 1];
|
||||
const lastComma = commaIndexes[commaIndexes.length - 1];
|
||||
const decimalSeparator = lastDot > lastComma ? '.' : ',';
|
||||
const decimalIndexes = decimalSeparator === '.' ? dotIndexes : commaIndexes;
|
||||
if (decimalIndexes.length !== 1) return undefined;
|
||||
decimalIndex = decimalIndexes[0];
|
||||
} else {
|
||||
const punctuationIndexes = dotIndexes.length > 0 ? dotIndexes : commaIndexes;
|
||||
if (punctuationIndexes.length === 1) {
|
||||
const index = punctuationIndexes[0];
|
||||
const left = groups[index];
|
||||
const right = groups[index + 1];
|
||||
const zeroInteger = /^0+$/.test(left);
|
||||
const looksLikeGrouping = !hasHardGrouping
|
||||
&& !zeroInteger
|
||||
&& left.length <= 3
|
||||
&& right.length === 3;
|
||||
if (!looksLikeGrouping) decimalIndex = index;
|
||||
} else if (punctuationIndexes.length > 1 && hasHardGrouping) {
|
||||
// Mixing a hard grouping style with repeated comma/dot separators is
|
||||
// ambiguous and almost always malformed (for example 1'234.567.89).
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (decimalIndex !== undefined && decimalIndex !== separators.length - 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const integerGroups = decimalIndex === undefined
|
||||
? groups
|
||||
: groups.slice(0, decimalIndex + 1);
|
||||
const groupingSeparators = separators.slice(0, Math.max(0, integerGroups.length - 1));
|
||||
const groupingStyles = new Set(groupingSeparators);
|
||||
if (groupingStyles.size > 1) return undefined;
|
||||
|
||||
if (integerGroups.length > 1) {
|
||||
const groupingStyle = groupingSeparators[0];
|
||||
const validGrouping = groupingStyle === ',' || groupingStyle === '.'
|
||||
? isWesternGrouping(integerGroups) || isIndianGrouping(integerGroups)
|
||||
: isWesternGrouping(integerGroups);
|
||||
if (!validGrouping) return undefined;
|
||||
}
|
||||
|
||||
const integerRaw = integerGroups.join('');
|
||||
const fractionRaw = decimalIndex === undefined ? '' : groups[decimalIndex + 1];
|
||||
if (integerRaw.length > MAX_INTEGER_DIGITS || fractionRaw.length > MAX_FRACTION_DIGITS) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const integer = integerRaw.replace(/^0+(?=\d)/, '') || '0';
|
||||
const fraction = fractionRaw.replace(/0+$/, '');
|
||||
return fraction.length > 0 ? `${integer}.${fraction}` : integer;
|
||||
}
|
||||
|
||||
function trimBounds(value: string): [number, number] {
|
||||
let start = 0;
|
||||
let end = value.length;
|
||||
while (start < end && /\s/u.test(value[start])) start += 1;
|
||||
while (end > start && /\s/u.test(value[end - 1])) end -= 1;
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one complete numeric expression to an exact canonical decimal.
|
||||
*
|
||||
* Supported signs are `+`, ASCII minus, Unicode minus, and accounting
|
||||
* parentheses. A leading plus is intentionally omitted from the canonical
|
||||
* positive value; negative zero is canonicalized to `0`.
|
||||
*/
|
||||
export function canonicalNumeric(raw: string): string | undefined {
|
||||
let [start, end] = trimBounds(raw);
|
||||
if (start === end) return undefined;
|
||||
|
||||
let negative = false;
|
||||
const parenthesized = raw[start] === '(' && raw[end - 1] === ')';
|
||||
if (parenthesized) {
|
||||
negative = true;
|
||||
start += 1;
|
||||
end -= 1;
|
||||
while (start < end && isHorizontalSpace(raw[start])) start += 1;
|
||||
while (end > start && isHorizontalSpace(raw[end - 1])) end -= 1;
|
||||
} else if (raw[start] === '(' || raw[end - 1] === ')') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sign = raw[start];
|
||||
if (sign === '+' || sign === '-' || sign === '\u2212') {
|
||||
if (parenthesized && sign === '+') return undefined;
|
||||
negative = negative || sign === '-' || sign === '\u2212';
|
||||
start += 1;
|
||||
while (start < end && isHorizontalSpace(raw[start])) start += 1;
|
||||
}
|
||||
|
||||
if (start === end) return undefined;
|
||||
const canonical = parseNumericBody(raw.slice(start, end));
|
||||
if (canonical === undefined) return undefined;
|
||||
return negative && canonical !== '0' ? `-${canonical}` : canonical;
|
||||
}
|
||||
|
||||
function isExactGroupingSpace(text: string, index: number): boolean {
|
||||
if (!isHorizontalSpace(text[index]) || !isDigit(text[index - 1])) return false;
|
||||
if (isHorizontalSpace(text[index + 1])) return false;
|
||||
return isDigit(text[index + 1])
|
||||
&& isDigit(text[index + 2])
|
||||
&& isDigit(text[index + 3])
|
||||
&& !isDigit(text[index + 4]);
|
||||
}
|
||||
|
||||
function numericBodyEnd(text: string, start: number): number {
|
||||
let index = start;
|
||||
while (index < text.length) {
|
||||
const char = text[index];
|
||||
if (isDigit(char)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (isHorizontalSpace(char)) {
|
||||
if (!isExactGroupingSpace(text, index)) break;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (isTightSeparator(char)) {
|
||||
const separatorStart = index;
|
||||
while (isTightSeparator(text[index])) index += 1;
|
||||
if (!isDigit(text[index])) return separatorStart;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize every numeric run in text without changing its UTF-16 length.
|
||||
*
|
||||
* Valid runs are replaced by their canonical ASCII decimal at the original
|
||||
* start offset and right-padded with spaces. Horizontal Unicode spaces/tabs
|
||||
* outside numeric runs become ASCII spaces. Malformed or oversized runs are
|
||||
* replaced character-for-character with U+FFFD, preventing downstream regexes
|
||||
* from backtracking into a valid-looking suffix of an invalid value.
|
||||
*/
|
||||
export function normalizeQuantitativeNumbers(text: string): string {
|
||||
const output: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < text.length) {
|
||||
const char = text[index];
|
||||
if (!isDigit(char)) {
|
||||
output.push(isHorizontalSpace(char) ? ' ' : normalizedQuantitativeSymbol(char));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const end = numericBodyEnd(text, index);
|
||||
const body = text.slice(index, end);
|
||||
const canonical = parseNumericBody(body);
|
||||
if (canonical === undefined || canonical.length > body.length) {
|
||||
output.push(MASK.repeat(body.length));
|
||||
} else {
|
||||
output.push(canonical, ' '.repeat(body.length - canonical.length));
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
|
||||
return output.join('');
|
||||
}
|
||||
153
packages/agent/src/sidecar-owned-process.ts
Normal file
153
packages/agent/src/sidecar-owned-process.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Keep a sidecar-owned process behind an IPC supervisor. The Tauri shell may
|
||||
* terminate the Node sidecar abruptly on Windows, bypassing every Fastify
|
||||
* shutdown hook. IPC loss is therefore the crash-safe ownership signal: the
|
||||
* supervisor survives long enough to terminate only its own process tree.
|
||||
*
|
||||
* Interactive `/api/tools/launch` processes intentionally do not use this
|
||||
* helper because they are user-owned and must survive a sidecar restart.
|
||||
*/
|
||||
export const SIDECAR_OWNED_PROCESS_SUPERVISOR_SOURCE = String.raw`
|
||||
const { spawn } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const executable = process.argv[1];
|
||||
const args = JSON.parse(process.argv[2] || '[]');
|
||||
const config = JSON.parse(process.argv[3] || '{}');
|
||||
const targetEnv = { ...process.env };
|
||||
delete targetEnv.NODE_CHANNEL_FD;
|
||||
delete targetEnv.NODE_UNIQUE_ID;
|
||||
const child = spawn(executable, args, {
|
||||
detached: process.platform !== 'win32',
|
||||
env: targetEnv,
|
||||
shell: false,
|
||||
stdio: ['inherit', 'inherit', 'inherit'],
|
||||
windowsHide: config.windowsHide !== false,
|
||||
windowsVerbatimArguments: config.windowsVerbatimArguments === true,
|
||||
});
|
||||
let stopping = false;
|
||||
let forceTimer;
|
||||
const finish = (code) => {
|
||||
if (forceTimer) clearTimeout(forceTimer);
|
||||
process.exit(code);
|
||||
};
|
||||
const finishLikeTarget = (code, signal) => {
|
||||
if (code !== null) return finish(code);
|
||||
if (!signal) return finish(0);
|
||||
process.removeListener('SIGTERM', stopTree);
|
||||
process.removeListener('SIGINT', stopTree);
|
||||
try { process.kill(process.pid, signal); } catch { finish(1); }
|
||||
};
|
||||
const reportTargetExit = (code, signal, done) => {
|
||||
if (typeof process.send !== 'function' || !process.connected) return done();
|
||||
let settled = false;
|
||||
const complete = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
done();
|
||||
};
|
||||
try {
|
||||
process.send({ type: 'waggle-sidecar-owned-process-exit', code, signal }, complete);
|
||||
setTimeout(complete, 250).unref();
|
||||
} catch { complete(); }
|
||||
};
|
||||
const stopTree = () => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
if (!child.pid) return finish(1);
|
||||
forceTimer = setTimeout(() => finish(1), 5000);
|
||||
if (process.platform === 'win32') {
|
||||
if (typeof config.taskkillPath !== 'string' || !path.win32.isAbsolute(config.taskkillPath)) {
|
||||
return finish(1);
|
||||
}
|
||||
const killer = spawn(config.taskkillPath, ['/PID', String(child.pid), '/T', '/F'], {
|
||||
windowsHide: true,
|
||||
shell: false,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
killer.once('error', () => finish(1));
|
||||
killer.once('exit', (code) => finish(code === 0 ? 0 : 1));
|
||||
return;
|
||||
}
|
||||
try { process.kill(-child.pid, 'SIGTERM'); } catch { try { child.kill('SIGTERM'); } catch {} }
|
||||
setTimeout(() => {
|
||||
try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch {} }
|
||||
finish(0);
|
||||
}, 1500).unref();
|
||||
};
|
||||
child.once('error', (error) => {
|
||||
try { process.stderr.write(String(error && error.message ? error.message : error)); } catch {}
|
||||
finish(1);
|
||||
});
|
||||
child.once('exit', (code, signal) => {
|
||||
reportTargetExit(code, signal, () => {
|
||||
if (!stopping) finishLikeTarget(code, signal);
|
||||
});
|
||||
});
|
||||
process.once('disconnect', stopTree);
|
||||
process.on('message', (message) => { if (message === 'shutdown') stopTree(); });
|
||||
process.once('SIGTERM', stopTree);
|
||||
process.once('SIGINT', stopTree);
|
||||
`;
|
||||
|
||||
export interface SidecarOwnedProcessOptions
|
||||
extends Omit<SpawnOptions, 'shell' | 'stdio' | 'windowsVerbatimArguments'> {
|
||||
stdio: SpawnOptions['stdio'];
|
||||
windowsVerbatimArguments?: boolean;
|
||||
}
|
||||
|
||||
export interface SidecarOwnedProcessExitMessage {
|
||||
type: 'waggle-sidecar-owned-process-exit';
|
||||
code: number | null;
|
||||
signal: string | null;
|
||||
}
|
||||
|
||||
export function isSidecarOwnedProcessExitMessage(
|
||||
value: unknown,
|
||||
): value is SidecarOwnedProcessExitMessage {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const message = value as Partial<SidecarOwnedProcessExitMessage>;
|
||||
return message.type === 'waggle-sidecar-owned-process-exit'
|
||||
&& (message.code === null || typeof message.code === 'number')
|
||||
&& (message.signal === null || typeof message.signal === 'string');
|
||||
}
|
||||
|
||||
export function resolveOwnedProcessTaskkillPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const candidate = env.SystemRoot ?? env.WINDIR;
|
||||
const windowsRoot = candidate && path.win32.isAbsolute(candidate)
|
||||
? path.win32.normalize(candidate)
|
||||
: 'C:\\Windows';
|
||||
return path.win32.join(windowsRoot, 'System32', 'taskkill.exe');
|
||||
}
|
||||
|
||||
export function spawnSidecarOwnedProcess(
|
||||
executable: string,
|
||||
args: string[],
|
||||
options: SidecarOwnedProcessOptions,
|
||||
): ChildProcess {
|
||||
if (!Array.isArray(options.stdio) || options.stdio.length !== 3) {
|
||||
throw new Error('Sidecar-owned processes require exactly stdin/stdout/stderr descriptors');
|
||||
}
|
||||
const targetConfig = JSON.stringify({
|
||||
taskkillPath: resolveOwnedProcessTaskkillPath(),
|
||||
windowsHide: options.windowsHide !== false,
|
||||
windowsVerbatimArguments: options.windowsVerbatimArguments === true,
|
||||
});
|
||||
return spawn(process.execPath, [
|
||||
'-e',
|
||||
SIDECAR_OWNED_PROCESS_SUPERVISOR_SOURCE,
|
||||
executable,
|
||||
JSON.stringify(args),
|
||||
targetConfig,
|
||||
], {
|
||||
...options,
|
||||
shell: false,
|
||||
stdio: [...options.stdio, 'ipc'],
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: false,
|
||||
});
|
||||
}
|
||||
@@ -15,8 +15,9 @@
|
||||
* ⇒ empty index (every skill defaults to UNVERIFIED — the badge is earned by a
|
||||
* clean passing grade, never assumed; fail-safe).
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export interface SkillAuditBadge {
|
||||
/** Crossed the verify threshold on a cleanly-parsed grade. */
|
||||
@@ -68,9 +69,26 @@ export function loadSkillAudit(waggleHome: string): SkillAuditIndex {
|
||||
export function saveSkillAudit(waggleHome: string, index: SkillAuditIndex): void {
|
||||
if (!fs.existsSync(waggleHome)) fs.mkdirSync(waggleHome, { recursive: true });
|
||||
const filePath = getSkillAuditPath(waggleHome);
|
||||
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(index, null, 2), 'utf-8');
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
const waitBuffer = new Int32Array(new SharedArrayBuffer(4));
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(index, null, 2), 'utf-8');
|
||||
for (let attempt = 1; attempt <= 10; attempt += 1) {
|
||||
try {
|
||||
fs.renameSync(tmpPath, filePath);
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
const transient = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
|
||||
if (!transient || attempt === 10) throw error;
|
||||
// Windows antivirus and indexers can briefly hold an exclusive handle.
|
||||
Atomics.wait(waitBuffer, 0, 0, 25 * attempt);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { fs.rmSync(tmpPath, { force: true }); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Upsert one badge, stamping auditedAt. Returns the stored entry. */
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* shows an amber "setup needed" badge instead.
|
||||
*/
|
||||
import { execFile } from 'node:child_process';
|
||||
import { win32 as pathWin32 } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { buildExternalProcessEnv } from './external-process-env.js';
|
||||
import { parseSkillFrontmatter } from './skill-frontmatter.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -34,6 +36,12 @@ export interface SkillRequirementDeps {
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface SkillBinLookupInvocation {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a skill's declared requirements from its raw markdown content.
|
||||
* Returns null when the skill declares none (no `requires:` block, or empty).
|
||||
@@ -49,12 +57,36 @@ export function extractSkillRequirements(content: string): SkillRequirements | n
|
||||
// Default bin lookup — adapted from tool-detection.ts defaultPathFromEnv
|
||||
// (module-private there; ~10 copied lines beat widening that file's export
|
||||
// surface). execFile with shell:false — bin names are never shell-expanded.
|
||||
function envValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
||||
const match = Object.entries(env).find(([key]) => key.toUpperCase() === name);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
export function buildSkillBinLookupInvocation(
|
||||
name: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
base: NodeJS.ProcessEnv = process.env,
|
||||
): SkillBinLookupInvocation {
|
||||
const env = buildExternalProcessEnv(base, {}, platform);
|
||||
if (platform !== 'win32') return { command: 'which', args: [name], env };
|
||||
const windowsRoot = envValue(env, 'SYSTEMROOT') ?? envValue(env, 'WINDIR') ?? 'C:\\Windows';
|
||||
return {
|
||||
command: pathWin32.join(windowsRoot, 'System32', 'where.exe'),
|
||||
// Windows `where.exe name` includes the current directory; $PATH confines
|
||||
// badge checks to PATH so workspace-local executables do not spoof setup.
|
||||
args: [`$PATH:${name}`],
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultHasBin(name: string): Promise<boolean> {
|
||||
const cmd = process.platform === 'win32' ? 'where.exe' : 'which';
|
||||
const invocation = buildSkillBinLookupInvocation(name);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(cmd, [name], {
|
||||
const { stdout } = await execFileAsync(invocation.command, invocation.args, {
|
||||
timeout: 3000,
|
||||
shell: false,
|
||||
env: invocation.env,
|
||||
windowsHide: true,
|
||||
});
|
||||
return stdout.split(/\r?\n/).some((l) => l.trim().length > 0);
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/**
|
||||
* Smart Model Router — heuristic classifier for budget model routing.
|
||||
* Smart Model Router - conservative classifier for budget model routing.
|
||||
*
|
||||
* A false primary route costs a little more. A false budget route can produce a
|
||||
* materially worse answer or send sensitive work to a differently configured
|
||||
* provider. Consequently, only a closed set of bounded, low-risk turns is sent
|
||||
* to the budget model; everything else stays on the user's primary model.
|
||||
*/
|
||||
|
||||
export interface RoutingDecision {
|
||||
@@ -7,7 +12,37 @@ export interface RoutingDecision {
|
||||
reason: 'simple_turn' | 'normal';
|
||||
}
|
||||
|
||||
const COMPLEX_KEYWORDS = /\b(debug|error|fix|refactor|implement|architect|design|analyze|review|migrate|deploy|build|test|create|generate|write|develop|configure|setup|install)\b/i;
|
||||
const PRIMARY_ROUTE_SIGNALS = [
|
||||
// Legal, regulatory, payroll, and employment decisions.
|
||||
/\b(?:legal|lawful|lawyer|attorney|court|lawsuit|litigat\w*|contract(?:ual)?|clause|indemnit\w*|liabilit\w*|compliance|regulat\w*|statute|jurisdiction|enforceab\w*|non[- ]?compete|nda|gdpr|hipaa|copyright|patent|trademark|subpoena|settlement)\b/i,
|
||||
/\b(?:payroll|pay[- ]?slip|paycheck|salary|wages?|overtime|withholding|tax(?:es)?|bonus|compensation|severance|benefits?|pension|employee|contractor|worker classification|deductions?|net pay|gross pay|filing)\b/i,
|
||||
|
||||
// Irreversible actions or actions with an external side effect.
|
||||
/\b(?:delete|remove|erase|drop|truncate|wipe|purge|destroy|overwrite|force[- ]?push|revoke|rotate|terminate|disable|shut ?down|kill|merge|commit|push|deploy|publish|send|email|submit|upload|transfer|purchase|execute|run|apply|production|database|repo(?:sitory)?|branches?)\b/i,
|
||||
|
||||
// Verification, coding, research, and other deliberative work.
|
||||
/\b(?:verify|validate|proof|prove|audit|double[- ]?check|fact[- ]?check|cross[- ]?check|reconcile|checksum|signed|evidence|trace|artifact)\b/i,
|
||||
/\b(?:debug|errors?|fix|refactor|implement|architect|design|analy[sz]e|review|migrate|build|tests?|create|generate|write|develop|configure|setup|install|code|function|class|module|api|sdk|bugs?|stack trace|exception|compiler|runtime|typescript|javascript|python|rust|sql|regex|git|docker|kubernetes|promise|async|race condition|null pointer|query|endpoint|dependency|schema|pull request)\b/i,
|
||||
/\b(?:research|sources?|citations?|cite|peer[- ]reviewed|compare|evaluate|assess|investigate|synthesi[sz]e|literature|stud(?:y|ies)|benchmark|forecast|latest|recent|news|guidance|nist)\b/i,
|
||||
|
||||
// Secrets, personal data, health data, and prompt-control attempts.
|
||||
/\b(?:confidential|private|sensitive|secret|credentials?|password|passcode|tokens?|api key|pii|ssn|social security|medical|diagnos\w*|health|patient|personal data|customer data|bank account|credit card|passport|identity|performance notes)\b/i,
|
||||
/\b(?:ignore (?:all |any )?(?:previous|prior) instructions?|system prompt|developer message|jailbreak)\b/i,
|
||||
] as const;
|
||||
|
||||
const TRIVIAL_TURN_PATTERNS = [
|
||||
/^(?:hi|hello|hey|good (?:morning|afternoon|evening))(?:\s+(?:there|everyone|team|all))?[!,.?]*$/i,
|
||||
/^(?:thanks|thank you|got it|okay|ok|sounds good|understood|you're welcome)[!,.?]*$/i,
|
||||
/^(?:what'?s the (?:current )?time|what time is it)(?:\s+(?:now|in [\p{L}\p{M} .'-]+))?[?!.]*$/iu,
|
||||
/^(?:what'?s today'?s date|what is today'?s date|what date is it|what day is it(?: today)?)[?!.]*$/iu,
|
||||
/^(?:please\s+)?translate\s+(?:"[^"\r\n]{1,120}"|'[^'\r\n]{1,120}'|[\p{L}\p{M}]+(?:\s+[\p{L}\p{M}]+){0,3})\s+(?:in)?to\s+[\p{L}\p{M}-]+(?:\s+please)?[?!.]*$/iu,
|
||||
/^(?:(?:what'?s|what is|calculate|compute)\s+)?[-+]?[\d,.]+(?:\s*(?:\+|-|\*|\/|%|mod|\^)\s*[-+]?[\d,.]+)+(?:\s*=\s*)?[?!.]*$/iu,
|
||||
/^(?:please\s+)?convert\s+[-+]?\d+(?:[.,]\d+)?\s+[\p{L}\p{M}/%]+\s+(?:in)?to\s+[\p{L}\p{M}/%]+[?!.]*$/iu,
|
||||
/^(?:how do you spell|spell)\s+[\p{L}\p{M}'-]+[?!.]*$/iu,
|
||||
/^what'?s the capital of [\p{L}\p{M} .'-]+[?!.]*$/iu,
|
||||
/^what is the capital of [\p{L}\p{M} .'-]+[?!.]*$/iu,
|
||||
/^(?:define\s+[\p{L}\p{M}'-]+|what does\s+[\p{L}\p{M}'-]+\s+mean)[?!.]*$/iu,
|
||||
] as const;
|
||||
|
||||
export function routeMessage(
|
||||
message: string,
|
||||
@@ -15,11 +50,18 @@ export function routeMessage(
|
||||
budgetModel: string | null,
|
||||
): RoutingDecision {
|
||||
if (!budgetModel) return { model: primaryModel, reason: 'normal' };
|
||||
if (message.length > 500) return { model: primaryModel, reason: 'normal' };
|
||||
if (message.split(/\s+/).filter(Boolean).length > 80) return { model: primaryModel, reason: 'normal' };
|
||||
if (message.includes('```') || message.includes('`')) return { model: primaryModel, reason: 'normal' };
|
||||
if (/https?:\/\//.test(message)) return { model: primaryModel, reason: 'normal' };
|
||||
|
||||
const normalized = message.trim();
|
||||
if (!normalized || message.length > 500) return { model: primaryModel, reason: 'normal' };
|
||||
if (normalized.split(/\s+/).length > 80) return { model: primaryModel, reason: 'normal' };
|
||||
if (message.includes('`')) return { model: primaryModel, reason: 'normal' };
|
||||
if (/https?:\/\//i.test(message)) return { model: primaryModel, reason: 'normal' };
|
||||
if ((message.match(/\n/g) || []).length >= 3) return { model: primaryModel, reason: 'normal' };
|
||||
if (COMPLEX_KEYWORDS.test(message)) return { model: primaryModel, reason: 'normal' };
|
||||
return { model: budgetModel, reason: 'simple_turn' };
|
||||
if (PRIMARY_ROUTE_SIGNALS.some(pattern => pattern.test(normalized))) {
|
||||
return { model: primaryModel, reason: 'normal' };
|
||||
}
|
||||
if (TRIVIAL_TURN_PATTERNS.some(pattern => pattern.test(normalized))) {
|
||||
return { model: budgetModel, reason: 'simple_turn' };
|
||||
}
|
||||
return { model: primaryModel, reason: 'normal' };
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import ExcelJS from 'exceljs';
|
||||
import type { ToolDefinition } from './tools.js';
|
||||
|
||||
function resolveSafe(workspace: string, filePath: string): string {
|
||||
const resolved = path.resolve(workspace, filePath);
|
||||
if (!resolved.startsWith(path.resolve(workspace))) {
|
||||
const root = path.resolve(workspace);
|
||||
const resolved = path.resolve(root, filePath);
|
||||
const relative = path.relative(root, resolved);
|
||||
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error(`Path resolves outside workspace: ${filePath}`);
|
||||
}
|
||||
return resolved;
|
||||
|
||||
@@ -26,6 +26,10 @@ export interface ParsedChatCompletionStream {
|
||||
toolCalls: StreamedToolCall[] | undefined;
|
||||
/** Usage from the final chunk carrying a `usage` block */
|
||||
usage: { inputTokens: number; outputTokens: number };
|
||||
/** Provider termination reason from the final choice chunk, when supplied. */
|
||||
finishReason: string | null;
|
||||
/** True only when the stream contained the protocol terminal `data: [DONE]` event. */
|
||||
doneObserved: boolean;
|
||||
}
|
||||
|
||||
export interface SseParseOptions {
|
||||
@@ -33,6 +37,29 @@ export interface SseParseOptions {
|
||||
onToken?: (token: string) => void;
|
||||
}
|
||||
|
||||
function incompleteStreamError(
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
partialToolCalls: StreamedToolCall[] | undefined,
|
||||
): Error & {
|
||||
code: 'INCOMPLETE_COMPLETION';
|
||||
usage: { inputTokens: number; outputTokens: number };
|
||||
partialToolCalls?: StreamedToolCall[];
|
||||
} {
|
||||
const error = new Error(
|
||||
'LLM stream ended unexpectedly before data: [DONE]; partial content was not accepted.',
|
||||
) as Error & {
|
||||
code: 'INCOMPLETE_COMPLETION';
|
||||
usage: { inputTokens: number; outputTokens: number };
|
||||
partialToolCalls?: StreamedToolCall[];
|
||||
};
|
||||
error.name = 'IncompleteCompletionError';
|
||||
error.code = 'INCOMPLETE_COMPLETION';
|
||||
error.usage = { inputTokens, outputTokens };
|
||||
error.partialToolCalls = partialToolCalls;
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an OpenAI-format SSE stream end-to-end and return assembled content +
|
||||
* tool calls + usage. Pure function over the stream — no caller state mutation
|
||||
@@ -47,6 +74,8 @@ export async function parseChatCompletionStream(
|
||||
let content = '';
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let finishReason: string | null = null;
|
||||
let doneObserved = false;
|
||||
const toolCalls = new Map<number, StreamedToolCall>();
|
||||
// Synthetic slot assignment for providers that omit `tc.index` on parallel
|
||||
// tool-call deltas: each distinct `tc.id` gets its own stable slot so their
|
||||
@@ -59,8 +88,18 @@ export async function parseChatCompletionStream(
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
streamRead: for (;;) {
|
||||
let readResult: ReadableStreamReadResult<Uint8Array>;
|
||||
try {
|
||||
readResult = await reader.read();
|
||||
} catch {
|
||||
throw incompleteStreamError(
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
toolCalls.size > 0 ? Array.from(toolCalls.values()) : undefined,
|
||||
);
|
||||
}
|
||||
const { done, value } = readResult;
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
@@ -74,7 +113,13 @@ export async function parseChatCompletionStream(
|
||||
for (const line of part.split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const payload = line.slice(6).trim();
|
||||
if (payload === '[DONE]') continue;
|
||||
if (payload === '[DONE]') {
|
||||
doneObserved = true;
|
||||
if (typeof reader.cancel === 'function') {
|
||||
void reader.cancel().catch(() => undefined);
|
||||
}
|
||||
break streamRead;
|
||||
}
|
||||
|
||||
let chunk: unknown;
|
||||
try {
|
||||
@@ -86,6 +131,7 @@ export async function parseChatCompletionStream(
|
||||
const c = chunk as {
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
||||
choices?: Array<{
|
||||
finish_reason?: string | null;
|
||||
delta?: {
|
||||
content?: string;
|
||||
tool_calls?: Array<{
|
||||
@@ -102,7 +148,10 @@ export async function parseChatCompletionStream(
|
||||
outputTokens = c.usage.completion_tokens ?? outputTokens;
|
||||
}
|
||||
|
||||
const delta = c.choices?.[0]?.delta;
|
||||
const choice = c.choices?.[0];
|
||||
if (choice?.finish_reason != null) finishReason = choice.finish_reason;
|
||||
|
||||
const delta = choice?.delta;
|
||||
if (!delta) continue;
|
||||
|
||||
if (delta.content) {
|
||||
@@ -154,5 +203,7 @@ export async function parseChatCompletionStream(
|
||||
content,
|
||||
toolCalls: toolCallsArray,
|
||||
usage: { inputTokens, outputTokens },
|
||||
finishReason,
|
||||
doneObserved,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,8 +9,15 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import type { ToolDefinition } from './tools.js';
|
||||
import type { AgentLoopConfig, AgentResponse } from './agent-loop.js';
|
||||
import type { HookRegistry } from './hooks.js';
|
||||
import { filterSpawnToolNames, type SpawnSecurityContext } from './subagent-tools.js';
|
||||
import { selectAgentRunBudget, type AgentRunBudgetPolicy } from './agent-run-budget.js';
|
||||
import { HookRegistry, type HookEvent } from './hooks.js';
|
||||
import {
|
||||
filterSpawnToolNames,
|
||||
guardSubAgentOutput,
|
||||
type SpawnSecurityContext,
|
||||
} from './subagent-tools.js';
|
||||
import { detectTaskShape } from './task-shape.js';
|
||||
import { filterAvailableTools, selectToolsForTurn } from './tool-filter.js';
|
||||
|
||||
export type WorkerStatus = 'pending' | 'running' | 'done' | 'failed';
|
||||
|
||||
@@ -53,6 +60,29 @@ export interface WorkflowTemplate {
|
||||
aggregation: 'concatenate' | 'last' | 'synthesize';
|
||||
}
|
||||
|
||||
export const MAX_WORKFLOW_STEPS = 32;
|
||||
export const MAX_WORKFLOW_CONCURRENCY = 5;
|
||||
export const MAX_WORKFLOW_TURNS = 96;
|
||||
export const MAX_WORKFLOW_CONFIGURED_TOKEN_BUDGET = 1_000_000;
|
||||
|
||||
export type WorkflowLimitKind = 'steps' | 'turns' | 'tokens';
|
||||
|
||||
export class WorkflowLimitError extends Error {
|
||||
constructor(
|
||||
public readonly kind: WorkflowLimitKind,
|
||||
public readonly actual: number,
|
||||
public readonly limit: number,
|
||||
) {
|
||||
const label = kind === 'steps'
|
||||
? 'worker'
|
||||
: kind === 'turns'
|
||||
? 'turn budget'
|
||||
: 'configured token budget';
|
||||
super(`Workflow ${label} limit exceeded: ${actual} > ${limit}`);
|
||||
this.name = 'WorkflowLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface OrchestratorConfig {
|
||||
availableTools: ToolDefinition[];
|
||||
runLoop: (config: AgentLoopConfig) => Promise<AgentResponse>;
|
||||
@@ -73,10 +103,24 @@ export interface OrchestratorConfig {
|
||||
getSpawnSecurityContext?: () => SpawnSecurityContext | undefined;
|
||||
}
|
||||
|
||||
interface WorkerExecutionPlan {
|
||||
tools: ToolDefinition[];
|
||||
runBudget: AgentRunBudgetPolicy;
|
||||
maxTurns: number;
|
||||
maxToolRounds: number;
|
||||
securityContext?: SpawnSecurityContext;
|
||||
}
|
||||
|
||||
interface WorkflowExecutionPlan {
|
||||
stepPlans: Map<WorkflowStep, WorkerExecutionPlan>;
|
||||
synthesisPlan?: WorkerExecutionPlan;
|
||||
}
|
||||
|
||||
export class SubagentOrchestrator extends EventEmitter {
|
||||
private config: OrchestratorConfig;
|
||||
private workers: Map<string, WorkerState>;
|
||||
private workflowCounter: number;
|
||||
private workflowRunning: boolean;
|
||||
private parentContext: string = '';
|
||||
|
||||
/** Role presets (same as subagent-tools for consistency, plus synthesizer/summarizer) */
|
||||
@@ -96,6 +140,7 @@ export class SubagentOrchestrator extends EventEmitter {
|
||||
this.config = config;
|
||||
this.workers = new Map();
|
||||
this.workflowCounter = 0;
|
||||
this.workflowRunning = false;
|
||||
}
|
||||
|
||||
/** Set parent agent context to inject into all worker prompts */
|
||||
@@ -115,6 +160,13 @@ export class SubagentOrchestrator extends EventEmitter {
|
||||
if (template.steps.length === 0) {
|
||||
return { results: new Map(), aggregated: '' };
|
||||
}
|
||||
if (this.workflowRunning) {
|
||||
throw new Error('A workflow is already running on this orchestrator instance');
|
||||
}
|
||||
this.workflowRunning = true;
|
||||
|
||||
try {
|
||||
const executionPlan = this.preflightWorkflow(template);
|
||||
|
||||
// Reset workers for this workflow run
|
||||
this.workers = new Map();
|
||||
@@ -176,23 +228,39 @@ export class SubagentOrchestrator extends EventEmitter {
|
||||
break;
|
||||
}
|
||||
|
||||
const wave = await Promise.all(
|
||||
ready.map((step) => this.runWorker(step, contextResults, stepWorkerIds.get(step.name))),
|
||||
);
|
||||
for (let index = 0; index < ready.length; index++) {
|
||||
const step = ready[index];
|
||||
const workerState = wave[index];
|
||||
if (workerState.status === 'done' && workerState.result) {
|
||||
contextResults.set(step.name, workerState.result);
|
||||
for (let offset = 0; offset < ready.length; offset += MAX_WORKFLOW_CONCURRENCY) {
|
||||
const batch = ready.slice(offset, offset + MAX_WORKFLOW_CONCURRENCY);
|
||||
const batchStates = await Promise.all(
|
||||
batch.map((step) => this.runWorker(
|
||||
step,
|
||||
contextResults,
|
||||
executionPlan.stepPlans.get(step)!,
|
||||
stepWorkerIds.get(step.name),
|
||||
)),
|
||||
);
|
||||
for (let index = 0; index < batch.length; index++) {
|
||||
const step = batch[index];
|
||||
const workerState = batchStates[index];
|
||||
if (workerState.status === 'done' && workerState.result) {
|
||||
contextResults.set(step.name, workerState.result);
|
||||
}
|
||||
completed.add(step.name);
|
||||
}
|
||||
completed.add(step.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate results
|
||||
const aggregated = await this.aggregateResults(this.workers, template.aggregation, contextResults);
|
||||
const aggregated = await this.aggregateResults(
|
||||
this.workers,
|
||||
template.aggregation,
|
||||
contextResults,
|
||||
executionPlan.synthesisPlan,
|
||||
);
|
||||
|
||||
return { results: new Map(this.workers), aggregated };
|
||||
return { results: new Map(this.workers), aggregated };
|
||||
} finally {
|
||||
this.workflowRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all workers and their current status */
|
||||
@@ -207,12 +275,126 @@ export class SubagentOrchestrator extends EventEmitter {
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────
|
||||
|
||||
private preflightWorkflow(template: WorkflowTemplate): WorkflowExecutionPlan {
|
||||
const implicitWorkerCount = template.aggregation === 'synthesize' ? 1 : 0;
|
||||
const workerCount = template.steps.length + implicitWorkerCount;
|
||||
if (workerCount > MAX_WORKFLOW_STEPS) {
|
||||
throw new WorkflowLimitError('steps', workerCount, MAX_WORKFLOW_STEPS);
|
||||
}
|
||||
|
||||
const securityContext = this.config.getSpawnSecurityContext?.();
|
||||
const stepPlans = new Map<WorkflowStep, WorkerExecutionPlan>();
|
||||
const stepPlanList = template.steps.map((step) => {
|
||||
const plan = this.buildWorkerExecutionPlan(step, securityContext);
|
||||
stepPlans.set(step, plan);
|
||||
return plan;
|
||||
});
|
||||
const synthesisPlan = template.aggregation === 'synthesize'
|
||||
? this.buildWorkerExecutionPlan({
|
||||
name: 'Synthesizer',
|
||||
role: 'synthesizer',
|
||||
task: 'Synthesize the completed worker results into a cohesive response.',
|
||||
tools: [],
|
||||
}, securityContext)
|
||||
: undefined;
|
||||
const plans = [...stepPlanList, ...(synthesisPlan ? [synthesisPlan] : [])];
|
||||
const totalTurns = plans.reduce((sum, plan) => sum + plan.maxTurns, 0);
|
||||
if (totalTurns > MAX_WORKFLOW_TURNS) {
|
||||
throw new WorkflowLimitError('turns', totalTurns, MAX_WORKFLOW_TURNS);
|
||||
}
|
||||
const totalTokenBudget = plans.reduce(
|
||||
(sum, plan) => sum + plan.runBudget.maxTokenBudget,
|
||||
0,
|
||||
);
|
||||
if (totalTokenBudget > MAX_WORKFLOW_CONFIGURED_TOKEN_BUDGET) {
|
||||
throw new WorkflowLimitError(
|
||||
'tokens',
|
||||
totalTokenBudget,
|
||||
MAX_WORKFLOW_CONFIGURED_TOKEN_BUDGET,
|
||||
);
|
||||
}
|
||||
|
||||
return { stepPlans, synthesisPlan };
|
||||
}
|
||||
|
||||
private buildWorkerExecutionPlan(
|
||||
step: WorkflowStep,
|
||||
securityContext?: SpawnSecurityContext,
|
||||
): WorkerExecutionPlan {
|
||||
const baseToolNames = step.tools
|
||||
?? SubagentOrchestrator.ROLE_TOOL_PRESETS[step.role]
|
||||
?? SubagentOrchestrator.ROLE_TOOL_PRESETS.analyst!;
|
||||
const toolNames = filterSpawnToolNames(baseToolNames, securityContext);
|
||||
const eligibleTools = filterAvailableTools(
|
||||
this.config.availableTools.filter(tool => toolNames.includes(tool.name)),
|
||||
);
|
||||
const tools = selectToolsForTurn(eligibleTools, {
|
||||
message: step.task,
|
||||
preferredToolNames: toolNames,
|
||||
fallbackToEligible: true,
|
||||
}).tools;
|
||||
const taskShape = detectTaskShape(step.task);
|
||||
const runBudget = selectAgentRunBudget({
|
||||
taskShape: taskShape.type,
|
||||
complexity: taskShape.complexity,
|
||||
selectedToolNames: tools.map(tool => tool.name),
|
||||
});
|
||||
const normalizedMaxTurns = Math.floor(step.maxTurns ?? 0);
|
||||
const requestedMaxTurns = Number.isFinite(normalizedMaxTurns) && normalizedMaxTurns >= 1
|
||||
? normalizedMaxTurns
|
||||
: runBudget.maxTurns;
|
||||
const maxTurns = Math.min(requestedMaxTurns, runBudget.maxTurns);
|
||||
|
||||
return {
|
||||
tools,
|
||||
runBudget,
|
||||
maxTurns,
|
||||
maxToolRounds: Math.min(runBudget.maxToolRounds, Math.max(0, maxTurns - 1)),
|
||||
securityContext,
|
||||
};
|
||||
}
|
||||
|
||||
private makeWorkerId(name: string): string {
|
||||
this.workflowCounter++;
|
||||
return `worker-${this.workflowCounter}-${Date.now()}`;
|
||||
}
|
||||
|
||||
private async runWorker(step: WorkflowStep, contextResults: Map<string, string>, existingId?: string): Promise<WorkerState> {
|
||||
private combineWorkerHooks(
|
||||
initialHooks?: HookRegistry,
|
||||
liveHooks?: HookRegistry,
|
||||
): HookRegistry | undefined {
|
||||
const originalHooks = initialHooks ?? this.config.hooks;
|
||||
if (!originalHooks) return liveHooks;
|
||||
if (!liveHooks || liveHooks === originalHooks) return originalHooks;
|
||||
|
||||
const combinedHooks = new HookRegistry();
|
||||
const workerEvents: HookEvent[] = [
|
||||
'pre:tool',
|
||||
'post:tool',
|
||||
'pre:memory-write',
|
||||
'post:memory-write',
|
||||
];
|
||||
for (const event of workerEvents) {
|
||||
combinedHooks.on(event, async (context) => {
|
||||
const originalResult = await originalHooks.fire(event, context);
|
||||
if (originalResult.cancelled) {
|
||||
return { cancel: true, reason: originalResult.reason };
|
||||
}
|
||||
const liveResult = await liveHooks.fire(event, context);
|
||||
return liveResult.cancelled
|
||||
? { cancel: true, reason: liveResult.reason }
|
||||
: undefined;
|
||||
});
|
||||
}
|
||||
return combinedHooks;
|
||||
}
|
||||
|
||||
private async runWorker(
|
||||
step: WorkflowStep,
|
||||
contextResults: Map<string, string>,
|
||||
executionPlan: WorkerExecutionPlan,
|
||||
existingId?: string,
|
||||
): Promise<WorkerState> {
|
||||
const id = existingId ?? this.makeWorkerId(step.name);
|
||||
// Reuse pre-created pending worker or create fresh
|
||||
const workerState: WorkerState = this.workers.get(id) ?? {
|
||||
@@ -230,13 +412,25 @@ export class SubagentOrchestrator extends EventEmitter {
|
||||
this.workers.set(id, workerState);
|
||||
this.emit('worker:status', { workerId: id, status: 'running', workerState });
|
||||
|
||||
// Resolve tools
|
||||
const baseToolNames = step.tools ?? SubagentOrchestrator.ROLE_TOOL_PRESETS[step.role] ?? SubagentOrchestrator.ROLE_TOOL_PRESETS.analyst!;
|
||||
// SEC: inherit the spawning request's governance denylist + persona allowlist
|
||||
// so a workflow worker cannot escape the request's tool restrictions.
|
||||
const secCtx = this.config.getSpawnSecurityContext?.();
|
||||
const toolNames = filterSpawnToolNames(baseToolNames, secCtx);
|
||||
const tools = this.config.availableTools.filter(t => toolNames.includes(t.name));
|
||||
const {
|
||||
tools: plannedTools,
|
||||
runBudget,
|
||||
maxTurns,
|
||||
maxToolRounds,
|
||||
securityContext,
|
||||
} = executionPlan;
|
||||
const liveSecurityContext = this.config.getSpawnSecurityContext?.();
|
||||
const liveToolNames = filterSpawnToolNames(
|
||||
plannedTools.map(tool => tool.name),
|
||||
liveSecurityContext,
|
||||
);
|
||||
const liveToolSet = new Set(liveToolNames);
|
||||
const tools = plannedTools.filter(tool => liveToolSet.has(tool.name));
|
||||
const blockedTools = [...new Set([
|
||||
...(securityContext?.blockedTools ?? []),
|
||||
...(liveSecurityContext?.blockedTools ?? []),
|
||||
])];
|
||||
const hooks = this.combineWorkerHooks(securityContext?.hooks, liveSecurityContext?.hooks);
|
||||
|
||||
// Build system prompt with optional context from previous steps
|
||||
const systemPrompt = this.buildWorkerContext(step, contextResults);
|
||||
@@ -249,21 +443,24 @@ export class SubagentOrchestrator extends EventEmitter {
|
||||
systemPrompt,
|
||||
tools,
|
||||
messages: [{ role: 'user', content: step.task }],
|
||||
maxTurns: step.maxTurns ?? 50,
|
||||
...runBudget,
|
||||
maxTurns,
|
||||
maxToolRounds,
|
||||
stream: false,
|
||||
signal: this.config.signal,
|
||||
// SEC: worker loops respect the request's approval gate + governance
|
||||
// denylist. The executeToolCall critical floor still fail-closes
|
||||
// destructive ops even when no gate is wired.
|
||||
hooks: secCtx?.hooks ?? this.config.hooks,
|
||||
governancePolicies: secCtx?.blockedTools?.length
|
||||
? { blockedTools: [...secCtx.blockedTools] }
|
||||
hooks,
|
||||
governancePolicies: blockedTools.length > 0
|
||||
? { blockedTools }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const response = guardSubAgentOutput(result.content, 'result');
|
||||
workerState.status = 'done';
|
||||
workerState.completedAt = Date.now();
|
||||
workerState.result = result.content;
|
||||
workerState.result = response;
|
||||
workerState.toolsUsed = result.toolsUsed;
|
||||
workerState.usage = { inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens };
|
||||
|
||||
@@ -271,7 +468,10 @@ export class SubagentOrchestrator extends EventEmitter {
|
||||
} catch (err) {
|
||||
workerState.status = 'failed';
|
||||
workerState.completedAt = Date.now();
|
||||
workerState.error = err instanceof Error ? err.message : String(err);
|
||||
workerState.error = guardSubAgentOutput(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
'error',
|
||||
);
|
||||
|
||||
this.emit('worker:status', { workerId: id, status: 'failed', workerState });
|
||||
}
|
||||
@@ -297,7 +497,8 @@ export class SubagentOrchestrator extends EventEmitter {
|
||||
}
|
||||
}
|
||||
if (contextParts.length > 0) {
|
||||
prompt += `\n## Previous Results\n${contextParts.join('\n\n')}\n`;
|
||||
const context = guardSubAgentOutput(contextParts.join('\n\n'), 'result');
|
||||
prompt += `\n## Previous Results\n${context}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +511,7 @@ export class SubagentOrchestrator extends EventEmitter {
|
||||
workers: Map<string, WorkerState>,
|
||||
mode: WorkflowTemplate['aggregation'],
|
||||
contextResults: Map<string, string>,
|
||||
synthesisPlan?: WorkerExecutionPlan,
|
||||
): Promise<string> {
|
||||
const workerList = Array.from(workers.values());
|
||||
const doneWorkers = workerList.filter(w => w.status === 'done' && w.result);
|
||||
@@ -318,33 +520,36 @@ export class SubagentOrchestrator extends EventEmitter {
|
||||
|
||||
switch (mode) {
|
||||
case 'concatenate': {
|
||||
return doneWorkers
|
||||
const combined = doneWorkers
|
||||
.map(w => `## ${w.name}\n${w.result}`)
|
||||
.join('\n\n');
|
||||
return guardSubAgentOutput(combined, 'result');
|
||||
}
|
||||
|
||||
case 'last': {
|
||||
return doneWorkers[doneWorkers.length - 1].result!;
|
||||
return guardSubAgentOutput(doneWorkers[doneWorkers.length - 1].result!, 'result');
|
||||
}
|
||||
|
||||
case 'synthesize': {
|
||||
// Spawn a synthesizer worker to combine all results
|
||||
const allResults = doneWorkers
|
||||
const combined = doneWorkers
|
||||
.map(w => `### ${w.name}\n${w.result}`)
|
||||
.join('\n\n');
|
||||
const allResults = guardSubAgentOutput(combined, 'result');
|
||||
|
||||
const synthesizeStep: WorkflowStep = {
|
||||
name: 'Synthesizer',
|
||||
role: 'synthesizer',
|
||||
task: `Synthesize the following results from multiple workers into a cohesive response:\n\n${allResults}`,
|
||||
tools: [],
|
||||
};
|
||||
|
||||
const synthState = await this.runWorker(synthesizeStep, contextResults);
|
||||
const synthState = await this.runWorker(synthesizeStep, contextResults, synthesisPlan!);
|
||||
return synthState.result ?? '';
|
||||
}
|
||||
|
||||
default:
|
||||
return doneWorkers.map(w => w.result).join('\n\n');
|
||||
return guardSubAgentOutput(doneWorkers.map(w => w.result).join('\n\n'), 'result');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
|
||||
import type { ToolDefinition } from './tools.js';
|
||||
import type { AgentLoopConfig, AgentResponse } from './agent-loop.js';
|
||||
import { evaluateExternalMemoryIngress } from '@waggle/core';
|
||||
import { selectAgentRunBudget } from './agent-run-budget.js';
|
||||
import type { HookRegistry } from './hooks.js';
|
||||
import { detectTaskShape } from './task-shape.js';
|
||||
import { filterAvailableTools, selectToolsForTurn } from './tool-filter.js';
|
||||
|
||||
/**
|
||||
* Request-scoped security context threaded into a spawned sub-agent / workflow
|
||||
@@ -152,6 +156,8 @@ export interface SubAgentToolsDeps {
|
||||
litellmApiKey: string;
|
||||
/** Default model for sub-agents */
|
||||
defaultModel?: string;
|
||||
/** Resolve an explicit child override before any durable run or model call. */
|
||||
resolveModel?: (model: string) => Promise<string>;
|
||||
/** Optional callback for streaming sub-agent progress */
|
||||
onSubAgentToken?: (agentId: string, token: string) => void;
|
||||
onSubAgentTool?: (agentId: string, name: string, input: Record<string, unknown>) => void;
|
||||
@@ -191,6 +197,14 @@ export interface SubAgentToolsDeps {
|
||||
runAdapter?: SubAgentRunAdapter;
|
||||
}
|
||||
|
||||
const QUARANTINED_AGENT_RESULT = '[Quarantined agent result: unsafe external content]';
|
||||
const QUARANTINED_AGENT_ERROR = '[Quarantined agent error: unsafe external content]';
|
||||
|
||||
export function guardSubAgentOutput(text: string, kind: 'result' | 'error'): string {
|
||||
if (evaluateExternalMemoryIngress({ content: text }).action === 'allow') return text;
|
||||
return kind === 'result' ? QUARANTINED_AGENT_RESULT : QUARANTINED_AGENT_ERROR;
|
||||
}
|
||||
|
||||
// In-memory registry of spawned sub-agents and their results
|
||||
const activeAgents = new Map<string, SubAgentDef>();
|
||||
const agentResults = new Map<string, SubAgentResult>();
|
||||
@@ -247,7 +261,7 @@ export const ROLE_TOOL_PRESETS: Record<string, string[]> = {
|
||||
};
|
||||
|
||||
export function createSubAgentTools(deps: SubAgentToolsDeps): ToolDefinition[] {
|
||||
const { availableTools, runLoop, litellmUrl, litellmApiKey, defaultModel } = deps;
|
||||
const { availableTools, runLoop, litellmApiKey, defaultModel } = deps;
|
||||
|
||||
return [
|
||||
// 1. spawn_agent — Create and run a specialist sub-agent
|
||||
@@ -270,7 +284,11 @@ export function createSubAgentTools(deps: SubAgentToolsDeps): ToolDefinition[] {
|
||||
description: 'Tool names to give the sub-agent (only used with role="custom"). Defaults to role preset.',
|
||||
},
|
||||
model: { type: 'string', description: 'Model to use (default: same as parent)' },
|
||||
max_turns: { type: 'number', description: 'Max turns before stopping (default: 50)' },
|
||||
max_turns: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
description: 'Optional upper bound; the task-aware safety budget may lower it.',
|
||||
},
|
||||
},
|
||||
required: ['name', 'role', 'task'],
|
||||
},
|
||||
@@ -279,8 +297,19 @@ export function createSubAgentTools(deps: SubAgentToolsDeps): ToolDefinition[] {
|
||||
const role = args.role as string;
|
||||
const task = args.task as string;
|
||||
const context = args.context as string ?? '';
|
||||
const model = args.model as string ?? defaultModel ?? 'claude-sonnet-4-6';
|
||||
const maxTurns = (args.max_turns as number) ?? 50;
|
||||
const requestedModel = args.model as string | undefined;
|
||||
let model = requestedModel ?? defaultModel ?? 'claude-sonnet-4-6';
|
||||
if (requestedModel !== undefined && deps.resolveModel) {
|
||||
try {
|
||||
model = await deps.resolveModel(requestedModel);
|
||||
} catch (err) {
|
||||
const errMsg = guardSubAgentOutput(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
'error',
|
||||
);
|
||||
return `## Sub-Agent Error: ${name}\n**Error:** Could not resolve the requested model: ${errMsg}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve tools for this sub-agent
|
||||
let toolNames: string[];
|
||||
@@ -295,7 +324,25 @@ export function createSubAgentTools(deps: SubAgentToolsDeps): ToolDefinition[] {
|
||||
// sub-agent — the blocked/denied tools are simply absent from its pool.
|
||||
const secCtx = deps.getSpawnSecurityContext?.();
|
||||
toolNames = filterSpawnToolNames(toolNames, secCtx);
|
||||
const subTools = availableTools.filter(t => toolNames.includes(t.name));
|
||||
const eligibleTools = filterAvailableTools(
|
||||
availableTools.filter(t => toolNames.includes(t.name)),
|
||||
);
|
||||
const subTools = selectToolsForTurn(eligibleTools, {
|
||||
message: task,
|
||||
preferredToolNames: toolNames,
|
||||
fallbackToEligible: true,
|
||||
}).tools;
|
||||
const taskShape = detectTaskShape(task);
|
||||
const runBudget = selectAgentRunBudget({
|
||||
taskShape: taskShape.type,
|
||||
complexity: taskShape.complexity,
|
||||
selectedToolNames: subTools.map(tool => tool.name),
|
||||
});
|
||||
const normalizedMaxTurns = Math.floor(Number(args.max_turns));
|
||||
const requestedMaxTurns = Number.isFinite(normalizedMaxTurns) && normalizedMaxTurns >= 1
|
||||
? normalizedMaxTurns
|
||||
: runBudget.maxTurns;
|
||||
const maxTurns = Math.min(requestedMaxTurns, runBudget.maxTurns);
|
||||
|
||||
// Generate a provisional ID. Hosts with a durable run registry replace
|
||||
// it with their canonical public run ID before execution starts.
|
||||
@@ -331,7 +378,10 @@ ${task}
|
||||
});
|
||||
if (runHandle?.runId) id = runHandle.runId;
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const errMsg = guardSubAgentOutput(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
'error',
|
||||
);
|
||||
return `## Sub-Agent Error: ${name}\n**Error:** Could not start the run: ${errMsg}`;
|
||||
}
|
||||
|
||||
@@ -360,14 +410,17 @@ ${task}
|
||||
startedAt: startTime,
|
||||
});
|
||||
try {
|
||||
const bufferedTokens: string[] = [];
|
||||
const result = await runLoop({
|
||||
litellmUrl,
|
||||
litellmUrl: deps.litellmUrl,
|
||||
litellmApiKey,
|
||||
model,
|
||||
systemPrompt,
|
||||
tools: subTools,
|
||||
messages: [{ role: 'user', content: task }],
|
||||
...runBudget,
|
||||
maxTurns,
|
||||
maxToolRounds: Math.min(runBudget.maxToolRounds, Math.max(0, maxTurns - 1)),
|
||||
stream: false, // Sub-agents don't stream to the user
|
||||
signal: runHandle?.signal,
|
||||
// W2.9 + SEC: sub-agents respect approval gates and memory validation
|
||||
@@ -380,7 +433,7 @@ ${task}
|
||||
? { blockedTools: [...secCtx.blockedTools] }
|
||||
: undefined,
|
||||
onToken: deps.onSubAgentToken
|
||||
? (token: string) => deps.onSubAgentToken!(id, token)
|
||||
? (token: string) => bufferedTokens.push(token)
|
||||
: undefined,
|
||||
onToolUse: deps.onSubAgentTool
|
||||
? (name: string, input: Record<string, unknown>) => deps.onSubAgentTool!(id, name, input)
|
||||
@@ -391,12 +444,20 @@ ${task}
|
||||
throw new Error('Sub-agent run was cancelled');
|
||||
}
|
||||
|
||||
const response = guardSubAgentOutput(result.content, 'result');
|
||||
const emittedContent = bufferedTokens.join('');
|
||||
if (deps.onSubAgentToken
|
||||
&& response === result.content
|
||||
&& guardSubAgentOutput(emittedContent, 'result') === emittedContent) {
|
||||
for (const token of bufferedTokens) deps.onSubAgentToken(id, token);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const subResult: SubAgentResult = {
|
||||
agentId: id,
|
||||
agentName: name,
|
||||
role,
|
||||
response: result.content,
|
||||
response,
|
||||
usage: { inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens },
|
||||
toolsUsed: result.toolsUsed,
|
||||
duration,
|
||||
@@ -439,11 +500,14 @@ ${task}
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
|
||||
return `## Sub-Agent Result: ${name}\n**Run ID:** ${id}\n**Role:** ${role}\n**Duration:** ${(duration / 1000).toFixed(1)}s\n**Tools used:** ${result.toolsUsed.join(', ') || 'none'}\n**Tokens:** ${result.usage.inputTokens + result.usage.outputTokens} total\n\n---\n\n${result.content}`;
|
||||
return `## Sub-Agent Result: ${name}\n**Run ID:** ${id}\n**Role:** ${role}\n**Duration:** ${(duration / 1000).toFixed(1)}s\n**Tools used:** ${result.toolsUsed.join(', ') || 'none'}\n**Tokens:** ${result.usage.inputTokens + result.usage.outputTokens} total\n\n---\n\n${response}`;
|
||||
} catch (err) {
|
||||
const duration = Date.now() - startTime;
|
||||
activeAgents.delete(id);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const errMsg = guardSubAgentOutput(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
'error',
|
||||
);
|
||||
const completedAt = Date.now();
|
||||
const cancelled = runHandle?.signal?.aborted ?? false;
|
||||
const failedResult: SubAgentResult = {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { execFileSync, spawn, type ChildProcess } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { Worker } from 'node:worker_threads';
|
||||
import { isSensitiveFilePath } from '@waggle/core';
|
||||
|
||||
/** Image file extensions (binary, should not be read as text) */
|
||||
export const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp']);
|
||||
@@ -14,15 +18,46 @@ export const DENIED_BINARIES = [
|
||||
'wscript', 'cscript', // Windows Script Host
|
||||
];
|
||||
|
||||
/** Environment variables to strip from child processes for security */
|
||||
/** Representative secret names retained for compatibility and regression tests. */
|
||||
export const SENSITIVE_ENV_VARS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'XAI_API_KEY',
|
||||
'DEEPSEEK_API_KEY',
|
||||
'MISTRAL_API_KEY',
|
||||
'DASHSCOPE_API_KEY',
|
||||
'MINIMAX_API_KEY',
|
||||
'ZHIPU_API_KEY',
|
||||
'MOONSHOT_API_KEY',
|
||||
'PERPLEXITY_API_KEY',
|
||||
'VOYAGE_API_KEY',
|
||||
'TAVILY_API_KEY',
|
||||
'BRAVE_API_KEY',
|
||||
'CLERK_SECRET_KEY',
|
||||
'STRIPE_SECRET_KEY',
|
||||
'STRIPE_WEBHOOK_SECRET',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'GITHUB_TOKEN',
|
||||
'GITLAB_TOKEN',
|
||||
'DATABASE_URL',
|
||||
'REDIS_URL',
|
||||
];
|
||||
|
||||
/** Minimal non-secret process context required by local runtimes. */
|
||||
const CHILD_ENV_ALLOWLIST = new Set([
|
||||
'PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'SYSTEMDRIVE', 'COMSPEC',
|
||||
'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH',
|
||||
'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA',
|
||||
'PROGRAMFILES', 'PROGRAMFILES(X86)', 'PROGRAMW6432',
|
||||
'TEMP', 'TMP', 'TMPDIR',
|
||||
'LANG', 'LC_ALL', 'LC_CTYPE', 'TERM', 'COLORTERM', 'TZ',
|
||||
'OS', 'PROCESSOR_ARCHITECTURE', 'PROCESSOR_IDENTIFIER', 'NUMBER_OF_PROCESSORS',
|
||||
]);
|
||||
|
||||
/** Maximum output size per stream (stdout/stderr) in bytes — 1 MB */
|
||||
export const MAX_OUTPUT_SIZE = 1024 * 1024;
|
||||
|
||||
@@ -41,16 +76,744 @@ export function checkDeniedBinaries(command: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sanitized copy of the process environment with sensitive vars removed.
|
||||
* Create a fail-closed child environment. Unknown variables are omitted so new
|
||||
* provider keys, run tokens, credential helpers, and infrastructure secrets do
|
||||
* not silently become available to model-invoked processes.
|
||||
*/
|
||||
export function createSanitizedEnv(): Record<string, string | undefined> {
|
||||
const sanitizedEnv = { ...process.env };
|
||||
for (const key of SENSITIVE_ENV_VARS) {
|
||||
delete sanitizedEnv[key];
|
||||
const sanitizedEnv: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (CHILD_ENV_ALLOWLIST.has(key.toUpperCase())) sanitizedEnv[key] = value;
|
||||
}
|
||||
return sanitizedEnv;
|
||||
}
|
||||
|
||||
/** Dispatch termination for a process and its descendants. */
|
||||
export function terminateProcessTree(
|
||||
child: ChildProcess,
|
||||
taskkillTimeoutMs = 5_000,
|
||||
): boolean {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return true;
|
||||
if (!child.pid) return false;
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const windowsRoot = process.env.SystemRoot ?? process.env.WINDIR ?? 'C:\\Windows';
|
||||
execFileSync(path.join(windowsRoot, 'System32', 'taskkill.exe'), [
|
||||
'/PID', String(child.pid), '/T', '/F',
|
||||
], {
|
||||
env: createSanitizedEnv(),
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
timeout: taskkillTimeoutMs,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return child.kill('SIGTERM');
|
||||
} catch {
|
||||
// Preserve best-effort parent cleanup for legacy callers, but do not count
|
||||
// it as proof that the Windows process tree settled.
|
||||
if (process.platform === 'win32') {
|
||||
try { child.kill('SIGKILL'); } catch { /* process may already be gone */ }
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
try {
|
||||
return child.kill('SIGKILL');
|
||||
} catch {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminate a real child and confirm that it actually settled. */
|
||||
export async function terminateProcessTreeAndWait(
|
||||
child: ChildProcess,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<boolean> {
|
||||
const hasExited = () => child.exitCode !== null || child.signalCode !== null;
|
||||
if (hasExited()) return true;
|
||||
if (!child.pid) return false;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let finished = false;
|
||||
|
||||
const onExit = () => finish(true);
|
||||
const finish = (confirmed: boolean) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
child.removeListener('exit', onExit);
|
||||
resolve(confirmed);
|
||||
};
|
||||
|
||||
child.once('exit', onExit);
|
||||
if (hasExited()) {
|
||||
finish(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const dispatched = terminateProcessTree(child, timeoutMs);
|
||||
if (finished) return;
|
||||
if (!dispatched || hasExited()) {
|
||||
finish(hasExited());
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingMs = Math.max(0, timeoutMs - (Date.now() - startedAt));
|
||||
if (remainingMs === 0) {
|
||||
finish(hasExited());
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
timer = setTimeout(() => finish(hasExited()), remainingMs);
|
||||
return;
|
||||
}
|
||||
|
||||
const gracefulWaitMs = Math.max(1, Math.floor(remainingMs / 2));
|
||||
timer = setTimeout(() => {
|
||||
if (hasExited()) {
|
||||
finish(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let forceDispatched = false;
|
||||
try {
|
||||
forceDispatched = child.kill('SIGKILL');
|
||||
} catch {
|
||||
forceDispatched = false;
|
||||
}
|
||||
if (finished) return;
|
||||
if (!forceDispatched && !hasExited()) {
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(
|
||||
() => finish(hasExited()),
|
||||
Math.max(1, remainingMs - gracefulWaitMs),
|
||||
);
|
||||
}, gracefulWaitMs);
|
||||
});
|
||||
}
|
||||
|
||||
export interface TimedProcessOptions {
|
||||
cwd: string;
|
||||
env: Record<string, string | undefined>;
|
||||
maxBuffer: number;
|
||||
windowsHide?: boolean;
|
||||
windowsVerbatimArguments?: boolean;
|
||||
}
|
||||
|
||||
export interface TimedProcessResult {
|
||||
cleanupDegraded: boolean;
|
||||
errorCode: string | number | null;
|
||||
errorMessage: string | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
const WINDOWS_PROCESS_WORKER_SOURCE = String.raw`
|
||||
void (async () => {
|
||||
const [{ parentPort, workerData }, { execFile, execFileSync }] = await Promise.all([
|
||||
import('node:worker_threads'),
|
||||
import('node:child_process'),
|
||||
]);
|
||||
if (!parentPort) throw new Error('Windows process supervisor has no parent port');
|
||||
|
||||
let child;
|
||||
let deadlineTimer;
|
||||
let outputDrainTimer;
|
||||
let settlementTimer;
|
||||
let processExited = false;
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
let cleanupDegraded = false;
|
||||
let outputLimitError;
|
||||
const requestedMaxBuffer = Math.max(1, Number(workerData.options.maxBuffer) || 1024 * 1024);
|
||||
const outputState = {
|
||||
stdout: { chunks: [], capturedBytes: 0, totalBytes: 0 },
|
||||
stderr: { chunks: [], capturedBytes: 0, totalBytes: 0 },
|
||||
};
|
||||
|
||||
const finish = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (deadlineTimer) clearTimeout(deadlineTimer);
|
||||
if (outputDrainTimer) clearTimeout(outputDrainTimer);
|
||||
if (settlementTimer) clearTimeout(settlementTimer);
|
||||
parentPort.postMessage({ type: 'result', ...result });
|
||||
};
|
||||
|
||||
const killOwnedProcess = () => {
|
||||
if (
|
||||
!child
|
||||
|| child.pid === undefined
|
||||
|| child.exitCode !== null
|
||||
|| child.signalCode !== null
|
||||
|| processExited
|
||||
) return 'none';
|
||||
try {
|
||||
execFileSync(workerData.taskkillPath, [
|
||||
'/PID', String(child.pid), '/T', '/F',
|
||||
], {
|
||||
env: workerData.options.env,
|
||||
stdio: 'ignore',
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
});
|
||||
return 'tree';
|
||||
} catch {
|
||||
try {
|
||||
return child.kill('SIGKILL') ? 'root' : 'none';
|
||||
} catch {
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const capturedOutput = (streamName) => Buffer.concat(outputState[streamName].chunks).toString('utf8');
|
||||
|
||||
const captureOutput = (streamName, chunk) => {
|
||||
const state = outputState[streamName];
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
state.totalBytes += buffer.length;
|
||||
const remaining = requestedMaxBuffer - state.capturedBytes;
|
||||
if (remaining > 0) {
|
||||
const captured = buffer.subarray(0, remaining);
|
||||
state.chunks.push(captured);
|
||||
state.capturedBytes += captured.length;
|
||||
}
|
||||
if (
|
||||
state.totalBytes <= requestedMaxBuffer
|
||||
|| outputLimitError
|
||||
|| processExited
|
||||
|| settled
|
||||
|| timedOut
|
||||
) return;
|
||||
|
||||
outputLimitError = {
|
||||
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
|
||||
message: streamName + ' maxBuffer length exceeded',
|
||||
};
|
||||
if (deadlineTimer) clearTimeout(deadlineTimer);
|
||||
const termination = killOwnedProcess();
|
||||
cleanupDegraded = termination !== 'tree';
|
||||
settlementTimer = setTimeout(() => {
|
||||
finish({
|
||||
cleanupDegraded,
|
||||
errorCode: outputLimitError.code,
|
||||
errorMessage: outputLimitError.message,
|
||||
stdout: capturedOutput('stdout'),
|
||||
stderr: capturedOutput('stderr'),
|
||||
timedOut: false,
|
||||
});
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const recordNaturalExit = () => {
|
||||
if (processExited || timedOut || settled) return;
|
||||
processExited = true;
|
||||
if (deadlineTimer) clearTimeout(deadlineTimer);
|
||||
if (outputLimitError) return;
|
||||
outputDrainTimer = setTimeout(() => {
|
||||
finish({
|
||||
cleanupDegraded: false,
|
||||
errorCode: null,
|
||||
errorMessage: 'Process exited but its output streams did not close; descendant processes may still be running',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: false,
|
||||
});
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
try {
|
||||
child = execFile(workerData.executable, workerData.args, {
|
||||
...workerData.options,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: requestedMaxBuffer * 2,
|
||||
}, (error) => {
|
||||
if (
|
||||
!outputLimitError
|
||||
&& processExited
|
||||
&& error?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
||||
) return;
|
||||
finish({
|
||||
cleanupDegraded,
|
||||
errorCode: outputLimitError?.code ?? error?.code ?? null,
|
||||
errorMessage: outputLimitError?.message ?? (error ? error.message : null),
|
||||
stdout: capturedOutput('stdout'),
|
||||
stderr: capturedOutput('stderr'),
|
||||
timedOut,
|
||||
});
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (chunk) => captureOutput('stdout', chunk));
|
||||
child.stderr?.on('data', (chunk) => captureOutput('stderr', chunk));
|
||||
child.once('exit', recordNaturalExit);
|
||||
|
||||
deadlineTimer = setTimeout(() => {
|
||||
if (settled || processExited || outputLimitError) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
recordNaturalExit();
|
||||
return;
|
||||
}
|
||||
timedOut = true;
|
||||
const termination = killOwnedProcess();
|
||||
if (termination === 'none') {
|
||||
cleanupDegraded = true;
|
||||
settlementTimer = setTimeout(() => {
|
||||
finish({
|
||||
cleanupDegraded,
|
||||
errorCode: null,
|
||||
errorMessage: 'Process timeout could not be enforced',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut,
|
||||
});
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
cleanupDegraded = termination === 'root';
|
||||
settlementTimer = setTimeout(() => {
|
||||
finish({
|
||||
cleanupDegraded,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: true,
|
||||
});
|
||||
}, 2000);
|
||||
}, Math.max(0, workerData.timeoutMs));
|
||||
} catch (error) {
|
||||
const termination = child ? killOwnedProcess() : 'none';
|
||||
finish({
|
||||
cleanupDegraded: termination === 'root',
|
||||
errorCode: null,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: false,
|
||||
});
|
||||
}
|
||||
})().catch((error) => {
|
||||
void import('node:worker_threads').then(({ parentPort }) => {
|
||||
parentPort?.postMessage({
|
||||
type: 'result',
|
||||
cleanupDegraded: false,
|
||||
errorCode: null,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
`;
|
||||
|
||||
const POSIX_PROCESS_SUPERVISOR_SOURCE = String.raw`
|
||||
const { execFileSync, spawn } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const report = (message) => {
|
||||
try { fs.writeSync(3, JSON.stringify(message) + '\n'); } catch { /* parent may already be gone */ }
|
||||
};
|
||||
|
||||
const deadlineAt = Number(process.argv[1]);
|
||||
const executable = process.argv[2];
|
||||
const args = process.argv.slice(3);
|
||||
let deadlineTimer;
|
||||
let managed;
|
||||
let spawnFailed = false;
|
||||
let timedOut = false;
|
||||
|
||||
const groupMembersRemain = () => {
|
||||
const listing = execFileSync('/bin/sh', [
|
||||
'-c',
|
||||
'printf "%s\\n" "$$"; exec ps -A -o pid= -o pgid=',
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
maxBuffer: 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 1000,
|
||||
});
|
||||
const lines = listing.trim().split(/\r?\n/);
|
||||
const probePid = Number(lines.shift());
|
||||
return lines.some((line) => {
|
||||
const [pidText, pgidText] = line.trim().split(/\s+/);
|
||||
const pid = Number(pidText);
|
||||
const pgid = Number(pgidText);
|
||||
return pgid === process.pid && pid !== process.pid && pid !== probePid;
|
||||
});
|
||||
};
|
||||
|
||||
const enforceTimeout = () => {
|
||||
timedOut = true;
|
||||
report({ type: 'timeout' });
|
||||
try {
|
||||
process.kill(-process.pid, 'SIGKILL');
|
||||
} catch {
|
||||
try { managed?.kill('SIGKILL'); } catch { /* managed root may already be gone */ }
|
||||
process.exitCode = 124;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
managed = spawn(executable, args, {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const remainingMs = Number.isFinite(deadlineAt) ? Math.max(0, deadlineAt - Date.now()) : 0;
|
||||
deadlineTimer = setTimeout(enforceTimeout, remainingMs);
|
||||
} catch (error) {
|
||||
report({ type: 'spawn-error', code: error?.code ?? null, message: error?.message ?? String(error) });
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (managed) {
|
||||
managed.stdout.pipe(process.stdout);
|
||||
managed.stderr.pipe(process.stderr);
|
||||
managed.once('error', (error) => {
|
||||
spawnFailed = true;
|
||||
report({ type: 'spawn-error', code: error.code ?? null, message: error.message });
|
||||
process.exitCode = 1;
|
||||
});
|
||||
managed.once('close', (code) => {
|
||||
if (timedOut) {
|
||||
process.exitCode = 124;
|
||||
return;
|
||||
}
|
||||
const managedExitCode = spawnFailed ? 1 : (typeof code === 'number' ? code : 1);
|
||||
const waitForGroupToSettle = () => {
|
||||
let membersRemain;
|
||||
try {
|
||||
membersRemain = groupMembersRemain();
|
||||
} catch (error) {
|
||||
report({ type: 'group-probe-failed', message: error?.message ?? String(error) });
|
||||
return;
|
||||
}
|
||||
if (membersRemain) {
|
||||
setTimeout(waitForGroupToSettle, 100);
|
||||
return;
|
||||
}
|
||||
if (deadlineTimer) clearTimeout(deadlineTimer);
|
||||
process.exitCode = managedExitCode;
|
||||
};
|
||||
waitForGroupToSettle();
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
function execFileWithMainThreadTimeout(
|
||||
executable: string,
|
||||
args: string[],
|
||||
options: TimedProcessOptions,
|
||||
timeoutMs: number,
|
||||
): Promise<TimedProcessResult> {
|
||||
return new Promise((resolve) => {
|
||||
const requestedMaxBuffer = Math.max(1, options.maxBuffer);
|
||||
const outputState = {
|
||||
stdout: { chunks: [] as Buffer[], capturedBytes: 0, totalBytes: 0 },
|
||||
stderr: { chunks: [] as Buffer[], capturedBytes: 0, totalBytes: 0 },
|
||||
};
|
||||
const { maxBuffer: _maxBuffer, ...spawnOptions } = options;
|
||||
const child = spawn(process.execPath, [
|
||||
'-e',
|
||||
POSIX_PROCESS_SUPERVISOR_SOURCE,
|
||||
String(Date.now() + Math.max(0, timeoutMs)),
|
||||
executable,
|
||||
...args,
|
||||
], {
|
||||
...spawnOptions,
|
||||
// The supervisor stays alive as the owned POSIX process-group leader
|
||||
// until the managed command and every inherited output pipe settle.
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let cleanupDegraded = false;
|
||||
let managedSpawnError: { code: string | number | null; message: string } | undefined;
|
||||
let outputLimitError: { code: string; message: string } | undefined;
|
||||
let processExited = false;
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
const ownedProcessGroupId = child.pid;
|
||||
const timeoutState: {
|
||||
settlementTimer?: ReturnType<typeof setTimeout>;
|
||||
} = {};
|
||||
|
||||
const capturedOutput = (streamName: 'stdout' | 'stderr') => (
|
||||
Buffer.concat(outputState[streamName].chunks).toString('utf8')
|
||||
);
|
||||
const finish = (
|
||||
errorCode: string | number | null,
|
||||
errorMessage: string | null,
|
||||
includeOutput = true,
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeoutState.settlementTimer) clearTimeout(timeoutState.settlementTimer);
|
||||
resolve({
|
||||
cleanupDegraded,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
stdout: includeOutput ? capturedOutput('stdout') : '',
|
||||
stderr: includeOutput ? capturedOutput('stderr') : '',
|
||||
timedOut,
|
||||
});
|
||||
};
|
||||
const killOwnedProcessGroup = (): 'tree' | 'root' | 'none' => {
|
||||
if (processExited || child.exitCode !== null || child.signalCode !== null) return 'none';
|
||||
const pid = child.pid;
|
||||
if (pid && pid > 0 && pid !== process.pid) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
return 'tree';
|
||||
} catch {
|
||||
// The process group may already have settled; try the root handle.
|
||||
}
|
||||
}
|
||||
try {
|
||||
return child.kill('SIGKILL') ? 'root' : 'none';
|
||||
} catch {
|
||||
return 'none';
|
||||
}
|
||||
};
|
||||
const finishIfStreamsDoNotClose = (errorMessage: string | null) => {
|
||||
if (timeoutState.settlementTimer) clearTimeout(timeoutState.settlementTimer);
|
||||
timeoutState.settlementTimer = setTimeout(() => {
|
||||
cleanupDegraded = true;
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
finish(outputLimitError?.code ?? null, outputLimitError?.message ?? errorMessage);
|
||||
}, 2000);
|
||||
};
|
||||
const finishAfterOwnedProcessGroupSettles = (
|
||||
errorCode: string | number | null,
|
||||
errorMessage: string | null,
|
||||
) => {
|
||||
if (timeoutState.settlementTimer) clearTimeout(timeoutState.settlementTimer);
|
||||
if (!ownedProcessGroupId || ownedProcessGroupId <= 0) {
|
||||
cleanupDegraded = true;
|
||||
finish(errorCode, errorMessage);
|
||||
return;
|
||||
}
|
||||
const deadlineAt = Date.now() + 1000;
|
||||
const checkSettlement = () => {
|
||||
let liveMemberRemains = true;
|
||||
try {
|
||||
const listing = execFileSync('/bin/ps', ['-A', '-o', 'pid=', '-o', 'pgid=', '-o', 'stat='], {
|
||||
encoding: 'utf8',
|
||||
env: options.env,
|
||||
maxBuffer: 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 250,
|
||||
});
|
||||
liveMemberRemains = listing.trim().split(/\r?\n/).some((line) => {
|
||||
const [pidText, pgidText, state] = line.trim().split(/\s+/);
|
||||
const pid = Number(pidText);
|
||||
const pgid = Number(pgidText);
|
||||
return pgid === ownedProcessGroupId
|
||||
&& pid !== process.pid
|
||||
&& (!state || !/^[ZX]/.test(state));
|
||||
});
|
||||
} catch {
|
||||
// Retry transient probe failures until the bounded deadline, then
|
||||
// preserve the existing degraded-cleanup warning.
|
||||
}
|
||||
|
||||
if (!liveMemberRemains) {
|
||||
finish(errorCode, errorMessage);
|
||||
return;
|
||||
}
|
||||
if (Date.now() >= deadlineAt) {
|
||||
cleanupDegraded = true;
|
||||
finish(errorCode, errorMessage);
|
||||
return;
|
||||
}
|
||||
timeoutState.settlementTimer = setTimeout(checkSettlement, 25);
|
||||
};
|
||||
checkSettlement();
|
||||
};
|
||||
const captureOutput = (streamName: 'stdout' | 'stderr', chunk: Buffer) => {
|
||||
const state = outputState[streamName];
|
||||
state.totalBytes += chunk.length;
|
||||
const remaining = requestedMaxBuffer - state.capturedBytes;
|
||||
if (remaining > 0) {
|
||||
const captured = chunk.subarray(0, remaining);
|
||||
state.chunks.push(captured);
|
||||
state.capturedBytes += captured.length;
|
||||
}
|
||||
if (
|
||||
state.totalBytes <= requestedMaxBuffer
|
||||
|| outputLimitError
|
||||
|| processExited
|
||||
|| settled
|
||||
|| timedOut
|
||||
) return;
|
||||
|
||||
outputLimitError = {
|
||||
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
|
||||
message: `${streamName} maxBuffer length exceeded`,
|
||||
};
|
||||
killOwnedProcessGroup();
|
||||
cleanupDegraded = true;
|
||||
finishIfStreamsDoNotClose(null);
|
||||
};
|
||||
|
||||
let controlBuffer = '';
|
||||
child.stdio[3]?.on('data', (chunk: Buffer) => {
|
||||
controlBuffer += chunk.toString('utf8');
|
||||
const lines = controlBuffer.split('\n');
|
||||
controlBuffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const message = JSON.parse(line) as {
|
||||
code?: string | number | null;
|
||||
message?: string;
|
||||
type?: string;
|
||||
};
|
||||
if (message.type === 'timeout') {
|
||||
timedOut = true;
|
||||
cleanupDegraded = true;
|
||||
finishIfStreamsDoNotClose(null);
|
||||
}
|
||||
if (message.type === 'group-probe-failed') cleanupDegraded = true;
|
||||
if (message.type === 'spawn-error' && message.message) {
|
||||
managedSpawnError = {
|
||||
code: message.code ?? null,
|
||||
message: message.message,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed supervisor diagnostics; normal exit still fails closed.
|
||||
}
|
||||
}
|
||||
});
|
||||
child.stdout?.on('data', (chunk: Buffer) => captureOutput('stdout', chunk));
|
||||
child.stderr?.on('data', (chunk: Buffer) => captureOutput('stderr', chunk));
|
||||
child.once('error', (error) => {
|
||||
finish((error as NodeJS.ErrnoException).code ?? null, error.message);
|
||||
});
|
||||
child.once('exit', () => {
|
||||
if (processExited || settled || timedOut) return;
|
||||
processExited = true;
|
||||
if (outputLimitError) return;
|
||||
timeoutState.settlementTimer = setTimeout(() => {
|
||||
cleanupDegraded = true;
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
finish(
|
||||
null,
|
||||
'Process exited but its output streams did not close; descendant processes may still be running',
|
||||
false,
|
||||
);
|
||||
}, 2000);
|
||||
});
|
||||
child.once('close', (code, signal) => {
|
||||
if (managedSpawnError) {
|
||||
finish(managedSpawnError.code, managedSpawnError.message);
|
||||
return;
|
||||
}
|
||||
if (outputLimitError) {
|
||||
finishAfterOwnedProcessGroupSettles(outputLimitError.code, outputLimitError.message);
|
||||
return;
|
||||
}
|
||||
if (timedOut) {
|
||||
finishAfterOwnedProcessGroupSettles(null, null);
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
const errorCode = code ?? signal ?? null;
|
||||
finish(errorCode, `Process exited with ${signal ? `signal ${signal}` : `code ${String(code)}`}`);
|
||||
return;
|
||||
}
|
||||
finish(null, null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a foreground process with a wall-clock timeout. The POSIX child
|
||||
* supervisor and Windows worker own their deadlines independently of caller
|
||||
* main-event-loop starvation.
|
||||
*/
|
||||
export function execFileWithTreeTimeout(
|
||||
executable: string,
|
||||
args: string[],
|
||||
options: TimedProcessOptions,
|
||||
timeoutMs: number,
|
||||
): Promise<TimedProcessResult> {
|
||||
if (process.platform !== 'win32') {
|
||||
return execFileWithMainThreadTimeout(executable, args, options, timeoutMs);
|
||||
}
|
||||
|
||||
const windowsRoot = process.env.SystemRoot ?? process.env.WINDIR ?? 'C:\\Windows';
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = new Worker(WINDOWS_PROCESS_WORKER_SOURCE, {
|
||||
eval: true,
|
||||
workerData: {
|
||||
args,
|
||||
executable,
|
||||
options,
|
||||
taskkillPath: path.join(windowsRoot, 'System32', 'taskkill.exe'),
|
||||
timeoutMs,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return Promise.resolve({
|
||||
cleanupDegraded: false,
|
||||
errorCode: null,
|
||||
errorMessage: `Windows process supervisor could not start: ${error instanceof Error ? error.message : String(error)}`,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: false,
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: TimedProcessResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
worker.removeAllListeners();
|
||||
void worker.terminate().catch(() => { /* worker already exited */ });
|
||||
resolve(result);
|
||||
};
|
||||
worker.once('message', (message: TimedProcessResult & { type?: string }) => {
|
||||
if (message.type !== 'result') return;
|
||||
finish(message);
|
||||
});
|
||||
worker.once('error', (error) => {
|
||||
finish({
|
||||
cleanupDegraded: false,
|
||||
errorCode: null,
|
||||
errorMessage: `Windows process supervisor failed: ${error.message}`,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: false,
|
||||
});
|
||||
});
|
||||
worker.once('exit', (code) => {
|
||||
finish({
|
||||
cleanupDegraded: false,
|
||||
errorCode: null,
|
||||
errorMessage: `Windows process supervisor exited with code ${code} before reporting a result`,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate output to MAX_OUTPUT_SIZE, appending a warning if truncated.
|
||||
*/
|
||||
@@ -63,10 +826,60 @@ export function truncateOutput(output: string): string {
|
||||
* Resolve a relative path within a workspace, rejecting traversal outside it.
|
||||
* Returns the resolved absolute path or throws.
|
||||
*/
|
||||
export function resolveSafe(workspace: string, filePath: string): string {
|
||||
const resolved = path.resolve(workspace, filePath);
|
||||
if (!resolved.startsWith(path.resolve(workspace))) {
|
||||
export interface ResolveSafeOptions {
|
||||
/** Deny well-known secret material when the workspace is linked to user storage. */
|
||||
denySensitiveFiles?: boolean;
|
||||
}
|
||||
|
||||
export class SensitiveFileAccessError extends Error {
|
||||
constructor() {
|
||||
super('Access to sensitive file denied');
|
||||
this.name = 'SensitiveFileAccessError';
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNonSensitiveFilePath(filePath: string): void {
|
||||
if (isSensitiveFilePath(filePath)) throw new SensitiveFileAccessError();
|
||||
}
|
||||
|
||||
export function resolveSafe(
|
||||
workspace: string,
|
||||
filePath: string,
|
||||
options: ResolveSafeOptions = {},
|
||||
): string {
|
||||
const workspaceRoot = path.resolve(workspace);
|
||||
const resolved = path.resolve(workspaceRoot, filePath);
|
||||
const relative = path.relative(workspaceRoot, resolved);
|
||||
const escapesLexically = relative === '..'
|
||||
|| relative.startsWith(`..${path.sep}`)
|
||||
|| path.isAbsolute(relative);
|
||||
if (escapesLexically) {
|
||||
throw new Error(`Path resolves outside workspace: ${filePath}`);
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && relative.split(path.sep).some((part) => part.includes(':'))) {
|
||||
throw new Error(`NTFS alternate data streams are not allowed: ${filePath}`);
|
||||
}
|
||||
|
||||
const realWorkspace = fs.realpathSync.native(workspaceRoot);
|
||||
let existingAncestor = resolved;
|
||||
while (!fs.existsSync(existingAncestor)) {
|
||||
const parent = path.dirname(existingAncestor);
|
||||
if (parent === existingAncestor) break;
|
||||
existingAncestor = parent;
|
||||
}
|
||||
const realAncestor = fs.realpathSync.native(existingAncestor);
|
||||
const realRelative = path.relative(realWorkspace, realAncestor);
|
||||
const escapesThroughLink = realRelative === '..'
|
||||
|| realRelative.startsWith(`..${path.sep}`)
|
||||
|| path.isAbsolute(realRelative);
|
||||
if (escapesThroughLink) {
|
||||
throw new Error(`Path resolves outside workspace through a link or junction: ${filePath}`);
|
||||
}
|
||||
|
||||
if (options.denySensitiveFiles) {
|
||||
assertNonSensitiveFilePath(relative);
|
||||
assertNonSensitiveFilePath(path.relative(realWorkspace, realAncestor));
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import { dedupTextResults, truncateToTokenBudget } from './tool-output-compresso
|
||||
import { safeFetch, allowLocalFromEnv, EgressBlockedError } from './url-egress-guard.js';
|
||||
import {
|
||||
IMAGE_EXTENSIONS, DENIED_BINARIES, SENSITIVE_ENV_VARS, MAX_OUTPUT_SIZE,
|
||||
checkDeniedBinaries, createSanitizedEnv, truncateOutput, resolveSafe,
|
||||
checkDeniedBinaries, createSanitizedEnv, execFileWithTreeTimeout, terminateProcessTree,
|
||||
truncateOutput, resolveSafe, assertNonSensitiveFilePath,
|
||||
} from './system-tools-helpers.js';
|
||||
|
||||
/**
|
||||
@@ -36,6 +37,43 @@ export interface SystemToolDeps {
|
||||
/** Optional storage backend (team S3/MinIO). If present, file-content
|
||||
* tools route through it instead of node:fs. */
|
||||
fileBackend?: FileBackend;
|
||||
/** Deny reads of well-known secret material for user-linked workspace roots. */
|
||||
denySensitiveFiles?: boolean;
|
||||
}
|
||||
|
||||
/** Prefer a repository README over GitHub navigation chrome for exact repo-root fetches. */
|
||||
export function extractWebPageText(body: string, sourceUrl: string): string {
|
||||
let content = body;
|
||||
try {
|
||||
const source = new URL(sourceUrl);
|
||||
const pathSegments = source.pathname.split('/').filter(Boolean);
|
||||
if (source.hostname.toLowerCase() === 'github.com' && pathSegments.length === 2) {
|
||||
const readme = body.match(
|
||||
/<article\b[^>]*class=(?:"[^"]*\bmarkdown-body\b[^"]*"|'[^']*\bmarkdown-body\b[^']*')[^>]*>([\s\S]*?)<\/article>/i,
|
||||
)?.[1];
|
||||
content = readme?.trim() ? readme : '';
|
||||
}
|
||||
} catch {
|
||||
// URL validation happens in web_fetch; direct helper callers fall back to the full page.
|
||||
}
|
||||
|
||||
return content
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<nav[\s\S]*?<\/nav>/gi, '')
|
||||
.replace(/<header[\s\S]*?<\/header>/gi, '')
|
||||
.replace(/<footer[\s\S]*?<\/footer>/gi, '')
|
||||
.replace(/<\/?(p|div|br|h[1-6]|li|tr|blockquote|section|article)[^>]*>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Module-level instances — shared across all tool invocations
|
||||
@@ -96,7 +134,7 @@ export function cleanupStaleTasks(): number {
|
||||
|
||||
export function createSystemTools(wsOrDeps: string | SystemToolDeps): ToolDefinition[] {
|
||||
const deps: SystemToolDeps = typeof wsOrDeps === 'string' ? { workspace: wsOrDeps } : wsOrDeps;
|
||||
const { workspace, fileBackend } = deps;
|
||||
const { workspace, fileBackend, denySensitiveFiles = false } = deps;
|
||||
|
||||
// Normalize a user-supplied path to a backend key. The fs-level resolveSafe
|
||||
// can't be used here because backend keys are virtual paths (e.g. S3 object
|
||||
@@ -115,11 +153,46 @@ export function createSystemTools(wsOrDeps: string | SystemToolDeps): ToolDefini
|
||||
return '/' + parts.join('/');
|
||||
};
|
||||
|
||||
const resolveReadableBackendKey = (userPath: string): string => {
|
||||
if (denySensitiveFiles) assertNonSensitiveFilePath(userPath);
|
||||
return resolveBackendKey(userPath);
|
||||
};
|
||||
|
||||
const resolveReadablePath = (userPath: string): string => resolveSafe(
|
||||
workspace,
|
||||
userPath,
|
||||
{ denySensitiveFiles },
|
||||
);
|
||||
|
||||
const filterReadableSearchPaths = (filePaths: string[]): string[] => {
|
||||
if (!denySensitiveFiles) {
|
||||
for (const filePath of filePaths) resolveSafe(workspace, filePath);
|
||||
return filePaths;
|
||||
}
|
||||
return filePaths.filter((filePath) => {
|
||||
try {
|
||||
resolveReadablePath(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
// Search must not disclose sensitive or link-escaped filenames.
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const validateWorkspaceGlob = (pattern: string): string => {
|
||||
if (typeof pattern !== 'string' || !pattern) throw new Error('Invalid glob pattern');
|
||||
if (path.isAbsolute(pattern)) throw new Error('Glob pattern must be relative to the workspace');
|
||||
resolveSafe(workspace, pattern);
|
||||
return pattern;
|
||||
};
|
||||
|
||||
return [
|
||||
// 1. bash — Execute shell commands
|
||||
{
|
||||
name: 'bash',
|
||||
description: 'Execute a shell command in the workspace directory',
|
||||
description: 'Execute an explicitly approved host shell command starting in the workspace directory. This is host-wide execution, not an OS sandbox.',
|
||||
riskLevel: 'high',
|
||||
offlineCapable: true,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
@@ -152,6 +225,7 @@ export function createSystemTools(wsOrDeps: string | SystemToolDeps): ToolDefini
|
||||
cwd: workspace,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
env: sanitizedEnv,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
const task: BackgroundTask = {
|
||||
@@ -188,30 +262,30 @@ export function createSystemTools(wsOrDeps: string | SystemToolDeps): ToolDefini
|
||||
return `Background task started. Task ID: ${taskId}`;
|
||||
}
|
||||
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), timeout);
|
||||
|
||||
return new Promise<string>((resolve) => {
|
||||
execFile(shell, shellArgs, {
|
||||
cwd: workspace,
|
||||
maxBuffer: MAX_OUTPUT_SIZE,
|
||||
signal: ac.signal,
|
||||
env: sanitizedEnv,
|
||||
}, (error, stdout, stderr) => {
|
||||
clearTimeout(timer);
|
||||
if (error) {
|
||||
if (error.code === 'ABORT_ERR') {
|
||||
resolve(`Error: Command timeout after ${timeout}ms`);
|
||||
return;
|
||||
}
|
||||
// Return stderr + stdout on non-zero exit (truncated)
|
||||
const output = truncateOutput((stderr || '') + (stdout || ''));
|
||||
resolve(output || `Error: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
resolve(truncateOutput(stdout));
|
||||
});
|
||||
});
|
||||
const result = await execFileWithTreeTimeout(shell, shellArgs, {
|
||||
cwd: workspace,
|
||||
maxBuffer: MAX_OUTPUT_SIZE,
|
||||
env: sanitizedEnv,
|
||||
windowsHide: true,
|
||||
}, timeout);
|
||||
if (result.timedOut) {
|
||||
const cleanupWarning = result.cleanupDegraded
|
||||
? ' Process-tree cleanup degraded to the root process; descendants may still be running.'
|
||||
: '';
|
||||
return `Error: Command timeout after ${timeout}ms.${cleanupWarning}`;
|
||||
}
|
||||
if (result.errorMessage) {
|
||||
// Return stderr + stdout on non-zero exit (truncated)
|
||||
const output = truncateOutput((result.stderr || '') + (result.stdout || ''));
|
||||
if (result.errorCode === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' || result.cleanupDegraded) {
|
||||
const cleanupWarning = result.cleanupDegraded
|
||||
? ' Process-tree cleanup was incomplete; descendants may still be running.'
|
||||
: '';
|
||||
return `Error: ${result.errorMessage}.${cleanupWarning}${output ? `\n${output}` : ''}`;
|
||||
}
|
||||
return output || `Error: ${result.errorMessage}`;
|
||||
}
|
||||
return truncateOutput(result.stdout);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -239,7 +313,7 @@ export function createSystemTools(wsOrDeps: string | SystemToolDeps): ToolDefini
|
||||
// backend routing for text files; images/PDFs stay on local disk until
|
||||
// Bucket 2 adds binary-stream support in the backend contract.
|
||||
if (!fileBackend) {
|
||||
const resolved = resolveSafe(workspace, filePath);
|
||||
const resolved = resolveReadablePath(filePath);
|
||||
|
||||
if (IMAGE_EXTENSIONS.has(ext)) {
|
||||
const stat = fs.statSync(resolved);
|
||||
@@ -272,11 +346,11 @@ export function createSystemTools(wsOrDeps: string | SystemToolDeps): ToolDefini
|
||||
if (ext === '.pdf') {
|
||||
return `[PDF file: ${filePath}, backend-routed read does not yet extract PDF text. Download the file to inspect it.]`;
|
||||
}
|
||||
const key = resolveBackendKey(filePath);
|
||||
const key = resolveReadableBackendKey(filePath);
|
||||
const buf = await fileBackend.read(key);
|
||||
content = buf.toString('utf-8');
|
||||
} else {
|
||||
const resolved = resolveSafe(workspace, filePath);
|
||||
const resolved = resolveReadablePath(filePath);
|
||||
content = fs.readFileSync(resolved, 'utf-8');
|
||||
}
|
||||
|
||||
@@ -420,11 +494,13 @@ export function createSystemTools(wsOrDeps: string | SystemToolDeps): ToolDefini
|
||||
},
|
||||
execute: async (args) => {
|
||||
try {
|
||||
const matches = await glob(args.pattern as string, {
|
||||
const pattern = validateWorkspaceGlob(args.pattern as string);
|
||||
const globMatches = await glob(pattern, {
|
||||
cwd: workspace,
|
||||
ignore: ['node_modules/**', '.git/**'],
|
||||
nodir: true,
|
||||
});
|
||||
const matches = filterReadableSearchPaths(globMatches);
|
||||
if (matches.length === 0) return 'No files found.';
|
||||
// A3: Cap file list to prevent token overflow
|
||||
const MAX_FILE_RESULTS = 200;
|
||||
@@ -472,21 +548,27 @@ export function createSystemTools(wsOrDeps: string | SystemToolDeps): ToolDefini
|
||||
|
||||
// If file_type is specified, override glob with extension-specific pattern
|
||||
if (fileType) {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9.+-]*$/.test(fileType)) {
|
||||
throw new Error('Invalid file_type extension');
|
||||
}
|
||||
filePattern = `**/*.${fileType}`;
|
||||
}
|
||||
|
||||
const files = await glob(filePattern, {
|
||||
validateWorkspaceGlob(filePattern);
|
||||
|
||||
const globMatches = await glob(filePattern, {
|
||||
cwd: workspace,
|
||||
ignore: ['node_modules/**', '.git/**'],
|
||||
nodir: true,
|
||||
});
|
||||
const files = filterReadableSearchPaths(globMatches);
|
||||
|
||||
if (outputMode === 'files') {
|
||||
// Return only file paths that contain matches
|
||||
const matchingFiles: string[] = [];
|
||||
for (const file of files) {
|
||||
if (maxResults !== undefined && matchingFiles.length >= maxResults) break;
|
||||
const absPath = path.join(workspace, file);
|
||||
const absPath = resolveReadablePath(file);
|
||||
try {
|
||||
const content = fs.readFileSync(absPath, 'utf-8');
|
||||
if (regex.test(content)) {
|
||||
@@ -504,7 +586,7 @@ export function createSystemTools(wsOrDeps: string | SystemToolDeps): ToolDefini
|
||||
// Return file paths with match counts
|
||||
const counts: string[] = [];
|
||||
for (const file of files) {
|
||||
const absPath = path.join(workspace, file);
|
||||
const absPath = resolveReadablePath(file);
|
||||
try {
|
||||
const content = fs.readFileSync(absPath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
@@ -530,7 +612,7 @@ export function createSystemTools(wsOrDeps: string | SystemToolDeps): ToolDefini
|
||||
|
||||
for (const file of files) {
|
||||
if (maxResults !== undefined && totalResults >= maxResults) break;
|
||||
const absPath = path.join(workspace, file);
|
||||
const absPath = resolveReadablePath(file);
|
||||
try {
|
||||
const content = fs.readFileSync(absPath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
@@ -734,23 +816,7 @@ export function createSystemTools(wsOrDeps: string | SystemToolDeps): ToolDefini
|
||||
}
|
||||
|
||||
// HTML — extract text
|
||||
const text = body
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<nav[\s\S]*?<\/nav>/gi, '')
|
||||
.replace(/<header[\s\S]*?<\/header>/gi, '')
|
||||
.replace(/<footer[\s\S]*?<\/footer>/gi, '')
|
||||
.replace(/<\/?(p|div|br|h[1-6]|li|tr|blockquote|section|article)[^>]*>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
const text = extractWebPageText(body, url);
|
||||
|
||||
if (!text) return 'Page fetched but no text content found.';
|
||||
return truncateToTokenBudget(text, maxTokens);
|
||||
@@ -896,10 +962,11 @@ export function createSystemTools(wsOrDeps: string | SystemToolDeps): ToolDefini
|
||||
},
|
||||
},
|
||||
|
||||
// 11. run_code — Execute code in a sandboxed environment
|
||||
// 11. run_code — Execute code in an explicitly approved host child process
|
||||
{
|
||||
name: 'run_code',
|
||||
description: 'Execute a code snippet in a sandboxed environment. Supports JavaScript/TypeScript and Python (if installed).',
|
||||
description: 'Execute an explicitly approved code snippet in a host child process starting in the workspace directory. This is host-wide execution, not an OS sandbox. Supports JavaScript/TypeScript and Python (if installed).',
|
||||
riskLevel: 'high',
|
||||
offlineCapable: true,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
@@ -916,66 +983,57 @@ export function createSystemTools(wsOrDeps: string | SystemToolDeps): ToolDefini
|
||||
const rawTimeout = (args.timeout as number) ?? 10_000;
|
||||
const timeout = Math.min(Math.max(rawTimeout, 1000), 30_000);
|
||||
|
||||
// Build the command depending on language
|
||||
let shell: string;
|
||||
let shellArgs: string[];
|
||||
const isWindows = process.platform === 'win32';
|
||||
let executable: string;
|
||||
let runtimeArgs: string[];
|
||||
|
||||
if (language === 'javascript' || language === 'typescript') {
|
||||
// Use node -e for both JS and TS (TS runs as JS via node — for full TS, tsx would be needed)
|
||||
shell = isWindows ? 'cmd.exe' : '/bin/sh';
|
||||
const nodeCmd = `node -e ${JSON.stringify(code)}`;
|
||||
shellArgs = isWindows ? ['/c', nodeCmd] : ['-c', nodeCmd];
|
||||
executable = process.execPath;
|
||||
runtimeArgs = language === 'typescript'
|
||||
? ['--experimental-strip-types', '-e', code]
|
||||
: ['-e', code];
|
||||
} else if (language === 'python') {
|
||||
shell = isWindows ? 'cmd.exe' : '/bin/sh';
|
||||
// Try python3 first on Unix, python on Windows
|
||||
const pythonBin = isWindows ? 'python' : 'python3';
|
||||
const pyCmd = `${pythonBin} -c ${JSON.stringify(code)}`;
|
||||
shellArgs = isWindows ? ['/c', pyCmd] : ['-c', pyCmd];
|
||||
executable = process.platform === 'win32' ? 'python' : 'python3';
|
||||
runtimeArgs = ['-c', code];
|
||||
} else {
|
||||
return `Error: Unsupported language "${language}". Supported: javascript, typescript, python.`;
|
||||
}
|
||||
|
||||
const sanitizedEnv = createSanitizedEnv();
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), timeout);
|
||||
|
||||
return new Promise<string>((resolve) => {
|
||||
execFile(shell, shellArgs, {
|
||||
cwd: workspace,
|
||||
maxBuffer: MAX_OUTPUT_SIZE,
|
||||
signal: ac.signal,
|
||||
env: sanitizedEnv,
|
||||
}, (error, stdout, stderr) => {
|
||||
clearTimeout(timer);
|
||||
const parts: string[] = [];
|
||||
const result = await execFileWithTreeTimeout(executable, runtimeArgs, {
|
||||
cwd: workspace,
|
||||
maxBuffer: MAX_OUTPUT_SIZE,
|
||||
env: sanitizedEnv,
|
||||
windowsHide: true,
|
||||
}, timeout);
|
||||
const parts: string[] = [];
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'ABORT_ERR') {
|
||||
resolve(`Error: Code execution timed out after ${timeout}ms`);
|
||||
return;
|
||||
}
|
||||
// Check for runtime not found
|
||||
if (error.code === 'ENOENT' || (error.message && error.message.includes('not found'))) {
|
||||
resolve(`Error: ${language} runtime not found. Please ensure ${language === 'python' ? 'python3/python' : 'node'} is installed and on PATH.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (result.timedOut) {
|
||||
const cleanupWarning = result.cleanupDegraded
|
||||
? ' Process-tree cleanup degraded to the root process; descendants may still be running.'
|
||||
: '';
|
||||
return `Error: Code execution timed out after ${timeout}ms.${cleanupWarning}`;
|
||||
}
|
||||
|
||||
if (stdout) parts.push(`--- stdout ---\n${truncateOutput(stdout)}`);
|
||||
if (stderr) parts.push(`--- stderr ---\n${truncateOutput(stderr)}`);
|
||||
if (result.errorMessage) {
|
||||
// Check for runtime not found
|
||||
if (result.errorCode === 'ENOENT' || result.errorMessage.includes('not found')) {
|
||||
return `Error: ${language} runtime not found. Please ensure ${language === 'python' ? 'python3/python' : 'node'} is installed and on PATH.`;
|
||||
}
|
||||
if (result.errorCode === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' || result.cleanupDegraded) {
|
||||
const cleanupWarning = result.cleanupDegraded
|
||||
? ' Process-tree cleanup was incomplete; descendants may still be running.'
|
||||
: '';
|
||||
parts.push(`--- error ---\n${result.errorMessage}.${cleanupWarning}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0 && error) {
|
||||
resolve(`Error: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
resolve('(no output)');
|
||||
return;
|
||||
}
|
||||
resolve(parts.join('\n'));
|
||||
});
|
||||
});
|
||||
if (result.stdout) parts.push(`--- stdout ---\n${truncateOutput(result.stdout)}`);
|
||||
if (result.stderr) parts.push(`--- stderr ---\n${truncateOutput(result.stderr)}`);
|
||||
|
||||
if (parts.length === 0 && result.errorMessage) return `Error: ${result.errorMessage}`;
|
||||
if (parts.length === 0) return '(no output)';
|
||||
return parts.join('\n');
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1003,7 +1061,7 @@ export function createSystemTools(wsOrDeps: string | SystemToolDeps): ToolDefini
|
||||
return `Task ${taskId} is already ${task.status}`;
|
||||
}
|
||||
|
||||
task.process.kill();
|
||||
terminateProcessTree(task.process);
|
||||
task.status = 'killed';
|
||||
return `Task ${taskId} has been killed`;
|
||||
},
|
||||
@@ -1017,6 +1075,6 @@ export { backgroundTasks, MAX_BACKGROUND_TASKS, STALE_TASK_THRESHOLD_MS };
|
||||
/** Re-export helpers so existing consumers keep working */
|
||||
export {
|
||||
DENIED_BINARIES, SENSITIVE_ENV_VARS, MAX_OUTPUT_SIZE,
|
||||
checkDeniedBinaries, createSanitizedEnv, truncateOutput,
|
||||
checkDeniedBinaries, createSanitizedEnv, terminateProcessTree, truncateOutput,
|
||||
resolveSafe,
|
||||
} from './system-tools-helpers.js';
|
||||
|
||||
@@ -110,6 +110,7 @@ const SHAPE_PATTERNS: ShapePattern[] = [
|
||||
/\bwhat\s+(would you|do you)\s+recommend\b/i,
|
||||
/\bhelp\s+(me\s+)?(decide|choose)\b/i,
|
||||
/\bmake\s+a\s+(decision|choice)\b/i,
|
||||
/\b(?:choose|rank|set)\s+(?:the\s+)?(?:order|priorit(?:y|ies))\b/i,
|
||||
],
|
||||
keywords: [
|
||||
/\bdecide\b/i, /\brecommend/i, /\badvise\b/i, /\bchoose\b/i,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { win32 as pathWin32 } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface ToolCommandInvocation {
|
||||
binary: string;
|
||||
@@ -11,6 +15,9 @@ export interface ToolCommandResolutionDeps {
|
||||
readTextFile?: (path: string) => string | null;
|
||||
fileExists?: (path: string) => boolean;
|
||||
nodeBinary?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pathLookup?: (binary: string, env: NodeJS.ProcessEnv) => Promise<string[]>;
|
||||
fallbackToWhere?: boolean;
|
||||
}
|
||||
|
||||
function defaultReadTextFile(path: string): string | null {
|
||||
@@ -26,12 +33,14 @@ function defaultFileExists(path: string): boolean {
|
||||
}
|
||||
|
||||
function npmCmdShimTarget(content: string): string | null {
|
||||
const match = content.match(/"%_prog%"\s+"%dp0%[\\/]+([^"]+)"/i);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
const packageShim = content.match(/"%_prog%"\s+"%dp0%[\\/]+([^"]+)"/i);
|
||||
if (packageShim?.[1]) return packageShim[1];
|
||||
|
||||
function quoteCmdArg(value: string): string {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
// npm 11's own npm.cmd/npx.cmd use NPM_CLI_JS/NPX_CLI_JS rather than
|
||||
// the package-shim `_prog` template. Resolve their local JS entrypoint so
|
||||
// model-supplied argv never passes through cmd.exe expansion.
|
||||
const npmCli = content.match(/SET\s+"(?:NPM|NPX)_CLI_JS=%~dp0[\\/]+([^"]+)"/i);
|
||||
return npmCli?.[1] ?? null;
|
||||
}
|
||||
|
||||
export function resolveToolCommandInvocation(
|
||||
@@ -54,17 +63,92 @@ export function resolveToolCommandInvocation(
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
binary: 'cmd.exe',
|
||||
args: [
|
||||
'/d',
|
||||
'/v:off',
|
||||
'/s',
|
||||
'/c',
|
||||
['call', quoteCmdArg(binary), ...args.map(quoteCmdArg)].join(' '),
|
||||
],
|
||||
windowsVerbatimArguments: true,
|
||||
};
|
||||
throw Object.assign(
|
||||
new Error(`UNSAFE_WINDOWS_BATCH_SHIM: Refusing unrecognized Windows batch shim: ${binary}`),
|
||||
{ code: 'UNSAFE_WINDOWS_BATCH_SHIM' },
|
||||
);
|
||||
}
|
||||
return { binary, args };
|
||||
}
|
||||
|
||||
function envValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
||||
const match = Object.entries(env).find(([key]) => key.toUpperCase() === name);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function windowsLookupEnv(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const name of ['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR']) {
|
||||
const value = envValue(source, name);
|
||||
if (value !== undefined) env[name] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
async function defaultWindowsPathLookup(
|
||||
binary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
fallbackToWhere: boolean,
|
||||
): Promise<string[]> {
|
||||
const pathValue = envValue(env, 'PATH') ?? '';
|
||||
const extension = pathWin32.extname(binary);
|
||||
const extensions = extension
|
||||
? ['']
|
||||
: (envValue(env, 'PATHEXT') ?? '.COM;.EXE;.BAT;.CMD')
|
||||
.split(';')
|
||||
.filter((item) => /^\.(?:exe|com|cmd|bat)$/i.test(item));
|
||||
for (const rawDir of pathValue.split(';')) {
|
||||
const directory = rawDir.trim().replace(/^"(.*)"$/, '$1');
|
||||
if (!directory) continue;
|
||||
for (const suffix of extensions) {
|
||||
const candidate = pathWin32.join(directory, `${binary}${suffix}`);
|
||||
if (existsSync(candidate)) return [candidate];
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackToWhere) return [];
|
||||
|
||||
// `where.exe` remains the authoritative fallback for App Execution Aliases
|
||||
// and other Windows resolution cases not represented as ordinary PATH files.
|
||||
const windowsRoot = envValue(env, 'SYSTEMROOT') ?? envValue(env, 'WINDIR') ?? 'C:\\Windows';
|
||||
const whereBinary = pathWin32.join(windowsRoot, 'System32', 'where.exe');
|
||||
const { stdout } = await execFileAsync(whereBinary, [binary], {
|
||||
env,
|
||||
timeout: 3_000,
|
||||
windowsHide: true,
|
||||
});
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((candidate) => candidate.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare Windows command through PATH before applying cmd-shim
|
||||
* handling. Node's shell-free spawn does not honor PATHEXT for npm/npx .cmd
|
||||
* shims, so spawning the logical name directly fails with ENOENT.
|
||||
*/
|
||||
export async function resolveToolCommandInvocationFromPath(
|
||||
binary: string,
|
||||
args: string[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
deps: ToolCommandResolutionDeps = {},
|
||||
): Promise<ToolCommandInvocation> {
|
||||
if (platform !== 'win32' || pathWin32.isAbsolute(binary) || /[\\/]/.test(binary)) {
|
||||
return resolveToolCommandInvocation(binary, args, platform, deps);
|
||||
}
|
||||
|
||||
const lookupEnv = windowsLookupEnv(deps.env ?? process.env);
|
||||
let candidates: string[];
|
||||
try {
|
||||
candidates = deps.pathLookup
|
||||
? await deps.pathLookup(binary, lookupEnv)
|
||||
: await defaultWindowsPathLookup(binary, lookupEnv, deps.fallbackToWhere !== false);
|
||||
} catch {
|
||||
// Preserve the original spawn error when PATH lookup itself fails.
|
||||
return resolveToolCommandInvocation(binary, args, platform, deps);
|
||||
}
|
||||
const resolved = candidates.find((candidate) => /\.(?:exe|com|cmd|bat)$/i.test(candidate));
|
||||
if (resolved) return resolveToolCommandInvocation(resolved, args, platform, deps);
|
||||
return resolveToolCommandInvocation(binary, args, platform, deps);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
type ToolManifest,
|
||||
} from '@waggle/shared';
|
||||
import { resolveToolCommandInvocation } from './tool-command.js';
|
||||
import { buildExternalProcessEnv } from './external-process-env.js';
|
||||
import { getToolRegistry } from './tool-registry.js';
|
||||
import type { ManifestLoaderDeps } from './tool-manifest-loader.js';
|
||||
import { resolveShellEnv, resolvedShellPath, mergePathValue } from './shell-env.js';
|
||||
@@ -60,6 +61,30 @@ import { resolveShellEnv, resolvedShellPath, mergePathValue } from './shell-env.
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CODEX_WINDOWS_APPS_DIAGNOSTIC =
|
||||
'Codex was found in WindowsApps, but Windows blocks command-line launch from that app alias. Install a PATH CLI build of Codex or launch Codex from Start, then refresh.';
|
||||
const HERMES_WINDOWS_HEALTH_DIAGNOSTIC =
|
||||
'Hermes is installed but failed its --version health check. Run "hermes doctor" or reinstall Hermes, then refresh.';
|
||||
type WindowsAppExecutables = Readonly<Record<string, readonly string[]>>;
|
||||
const EMPTY_WINDOWS_APP_EXECUTABLES: WindowsAppExecutables = {};
|
||||
const WINDOWS_APPX_QUERY = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
"$targets = @(@{ Id = 'claude-desktop'; Name = 'Claude' }, @{ Id = 'codex-desktop'; Name = 'OpenAI.Codex' })",
|
||||
'$result = @{}',
|
||||
'foreach ($target in $targets) {',
|
||||
' $paths = @()',
|
||||
' Get-AppxPackage -Name $target.Name -ErrorAction SilentlyContinue | ForEach-Object {',
|
||||
' $package = $_',
|
||||
' $manifest = Get-AppxPackageManifest -Package $package.PackageFullName',
|
||||
' foreach ($app in @($manifest.Package.Applications.Application)) {',
|
||||
' $executable = [string]$app.Executable',
|
||||
' if (-not [string]::IsNullOrWhiteSpace($executable)) {',
|
||||
' $paths += [IO.Path]::GetFullPath((Join-Path $package.InstallLocation $executable))',
|
||||
' }',
|
||||
' }',
|
||||
' }',
|
||||
' $result[$target.Id] = @($paths)',
|
||||
'}',
|
||||
'$result | ConvertTo-Json -Compress -Depth 3',
|
||||
].join('\n');
|
||||
|
||||
function isBlockedWindowsAppsCodexPath(
|
||||
id: string,
|
||||
@@ -84,6 +109,8 @@ export interface ToolDetectionDeps {
|
||||
platform?: NodeJS.Platform;
|
||||
/** $HOME override (defaults to os.homedir()). */
|
||||
home?: string;
|
||||
/** Environment override for platform-specific config roots. */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Working directory override (defaults to process.cwd()). */
|
||||
cwd?: string;
|
||||
/**
|
||||
@@ -110,6 +137,12 @@ export interface ToolDetectionDeps {
|
||||
* `where.exe` on win32 and `which` on POSIX.
|
||||
*/
|
||||
pathFromEnv?: (name: string) => string | Promise<string | null> | null;
|
||||
/**
|
||||
* Registered Windows Store application executables, keyed by built-in tool
|
||||
* id. Injected so AppX discovery stays hermetic in tests.
|
||||
*/
|
||||
windowsAppExecutables?: () =>
|
||||
WindowsAppExecutables | Promise<WindowsAppExecutables>;
|
||||
}
|
||||
|
||||
// ── Default deps (production-only paths) ────────────────────────────
|
||||
@@ -123,14 +156,21 @@ async function defaultExists(p: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultExecVersion(
|
||||
export async function defaultExecVersion(
|
||||
binary: string,
|
||||
args: string[],
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const invocation = resolveToolCommandInvocation(binary, args);
|
||||
const env = buildExternalProcessEnv(process.env);
|
||||
const invocation = resolveToolCommandInvocation(
|
||||
binary,
|
||||
args,
|
||||
process.platform,
|
||||
{ env },
|
||||
);
|
||||
const { stdout } = await execFileAsync(invocation.binary, invocation.args, {
|
||||
timeout: 5000,
|
||||
env,
|
||||
// Don't allow shell expansion; binary paths must be literal.
|
||||
shell: false,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
|
||||
@@ -152,30 +192,54 @@ async function defaultReadJson(p: string): Promise<unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Env for the `which`/`where` lookup. On POSIX, merge the resolved login-shell
|
||||
* Env for the `which`/`where` lookup. Fail closed to the non-secret external
|
||||
* process environment. On POSIX, merge the resolved login-shell
|
||||
* PATH (GUI-launched sidecars inherit a bare PATH) so `which claude`
|
||||
* can find CLIs installed behind shell-profile shims. Returns `undefined` (keep
|
||||
* the inherited env) on Windows or when no login-shell PATH is available yet.
|
||||
* can find CLIs installed behind shell-profile shims.
|
||||
*/
|
||||
export function pathLookupEnv(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
base: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv | undefined {
|
||||
if (platform === 'win32') return undefined;
|
||||
): NodeJS.ProcessEnv {
|
||||
const env = buildExternalProcessEnv(base, {}, platform);
|
||||
if (platform === 'win32') return env;
|
||||
const shellPath = resolvedShellPath();
|
||||
if (!shellPath) return undefined;
|
||||
return { ...base, PATH: mergePathValue(shellPath, base.PATH) };
|
||||
if (shellPath) env.PATH = mergePathValue(shellPath, env.PATH);
|
||||
return env;
|
||||
}
|
||||
|
||||
function envValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
||||
const match = Object.entries(env).find(([key]) => key.toUpperCase() === name);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
export function pathLookupCommand(
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string {
|
||||
if (platform !== 'win32') return 'which';
|
||||
const windowsRoot = envValue(env, 'SYSTEMROOT') ?? envValue(env, 'WINDIR') ?? 'C:\\Windows';
|
||||
return pathWin32.join(windowsRoot, 'System32', 'where.exe');
|
||||
}
|
||||
|
||||
export function pathLookupArgs(
|
||||
platform: NodeJS.Platform,
|
||||
name: string,
|
||||
): string[] {
|
||||
// Windows `where.exe name` searches the current directory before PATH. The
|
||||
// $PATH: prefix confines lookup to PATH and prevents launch-cwd hijacks.
|
||||
return [platform === 'win32' ? `$PATH:${name}` : name];
|
||||
}
|
||||
|
||||
async function defaultPathFromEnv(name: string): Promise<string | null> {
|
||||
const isWin = process.platform === 'win32';
|
||||
const cmd = isWin ? 'where.exe' : 'which';
|
||||
const env = pathLookupEnv(process.platform);
|
||||
const cmd = pathLookupCommand(process.platform, env);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(cmd, [name], {
|
||||
const { stdout } = await execFileAsync(cmd, pathLookupArgs(process.platform, name), {
|
||||
timeout: 3000,
|
||||
shell: false,
|
||||
...(env ? { env } : {}),
|
||||
env,
|
||||
windowsHide: true,
|
||||
});
|
||||
return selectPathLookupCandidate(stdout, process.platform);
|
||||
} catch {
|
||||
@@ -183,6 +247,44 @@ async function defaultPathFromEnv(name: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultWindowsAppExecutables(): Promise<WindowsAppExecutables> {
|
||||
if (process.platform !== 'win32') return EMPTY_WINDOWS_APP_EXECUTABLES;
|
||||
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
|
||||
if (!systemRoot) return EMPTY_WINDOWS_APP_EXECUTABLES;
|
||||
const powershell = pathWin32.join(
|
||||
systemRoot,
|
||||
'System32',
|
||||
'WindowsPowerShell',
|
||||
'v1.0',
|
||||
'powershell.exe',
|
||||
);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
powershell,
|
||||
['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_APPX_QUERY],
|
||||
{ timeout: 5000, shell: false, windowsHide: true },
|
||||
);
|
||||
const parsed = JSON.parse(stdout) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return EMPTY_WINDOWS_APP_EXECUTABLES;
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const id of ['claude-desktop', 'codex-desktop']) {
|
||||
const values = record[id];
|
||||
result[id] = Array.isArray(values)
|
||||
? values.filter(
|
||||
(value): value is string =>
|
||||
typeof value === 'string' && pathWin32.isAbsolute(value),
|
||||
)
|
||||
: [];
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return EMPTY_WINDOWS_APP_EXECUTABLES;
|
||||
}
|
||||
}
|
||||
|
||||
export function selectPathLookupCandidate(
|
||||
stdout: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
@@ -204,18 +306,22 @@ export function selectPathLookupCandidate(
|
||||
interface ResolvedDeps {
|
||||
platform: NodeJS.Platform;
|
||||
home: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
cwd: string;
|
||||
exists: (p: string) => Promise<boolean>;
|
||||
execVersion: (binary: string, args: string[]) => Promise<string | null>;
|
||||
readJson: (p: string) => Promise<unknown>;
|
||||
pathFromEnv: (name: string) => Promise<string | null>;
|
||||
windowsAppExecutables: () => Promise<WindowsAppExecutables>;
|
||||
}
|
||||
|
||||
function resolveDeps(opts: ToolDetectionDeps): ResolvedDeps {
|
||||
const pathFromEnvOpt = opts.pathFromEnv;
|
||||
let windowsAppExecutablesPromise: Promise<WindowsAppExecutables> | undefined;
|
||||
return {
|
||||
platform: opts.platform ?? osPlatform(),
|
||||
home: opts.home ?? homedir(),
|
||||
env: opts.env ?? process.env,
|
||||
cwd: opts.cwd ?? process.cwd(),
|
||||
exists: opts.exists ?? defaultExists,
|
||||
execVersion: opts.execVersion ?? defaultExecVersion,
|
||||
@@ -227,23 +333,181 @@ function resolveDeps(opts: ToolDetectionDeps): ResolvedDeps {
|
||||
const result = pathFromEnvOpt(name);
|
||||
return result instanceof Promise ? await result : result;
|
||||
},
|
||||
windowsAppExecutables: () => {
|
||||
windowsAppExecutablesPromise ??= Promise.resolve()
|
||||
.then(() => opts.windowsAppExecutables?.() ?? defaultWindowsAppExecutables())
|
||||
.catch(() => EMPTY_WINDOWS_APP_EXECUTABLES);
|
||||
return windowsAppExecutablesPromise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Hook status (shared across all tools) ───────────────────────────
|
||||
|
||||
// Hook-pointer paths + display names come from each tool's ToolManifest
|
||||
// (the registry — #5). probeHooks takes the resolved relative pointer directly,
|
||||
// so third-party adapters and the Claude Desktop MCP bridge need no per-tool map.
|
||||
// Hook-pointer paths + roots come from each tool's ToolManifest (the registry —
|
||||
// #5), so third-party adapters and the Claude Desktop MCP bridge need no map.
|
||||
|
||||
interface HookProbe {
|
||||
hooksInstalled: boolean;
|
||||
hookPointerPath: string | null;
|
||||
}
|
||||
|
||||
async function probeHooks(rel: string, deps: ResolvedDeps): Promise<HookProbe> {
|
||||
const CLAUDE_CODE_HOOKS = [
|
||||
['SessionStart', 'session-start'],
|
||||
['UserPromptSubmit', 'user-prompt-submit'],
|
||||
['Stop', 'stop'],
|
||||
['PreCompact', 'pre-compact'],
|
||||
] as const;
|
||||
|
||||
function objectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function tokenizeHookCommand(command: string): string[] | null {
|
||||
if (/[\r\n\0]/.test(command)) return null;
|
||||
|
||||
const tokens: string[] = [];
|
||||
let index = 0;
|
||||
while (index < command.length) {
|
||||
while (command[index] === ' ' || command[index] === '\t') index += 1;
|
||||
if (index >= command.length) break;
|
||||
|
||||
if (command[index] === '"') {
|
||||
const end = command.indexOf('"', index + 1);
|
||||
if (end === -1 || end === index + 1) return null;
|
||||
const token = command.slice(index + 1, end);
|
||||
if (/[$`%!]/.test(token)) return null;
|
||||
tokens.push(token);
|
||||
index = end + 1;
|
||||
if (index < command.length && command[index] !== ' ' && command[index] !== '\t') return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const start = index;
|
||||
while (index < command.length && command[index] !== ' ' && command[index] !== '\t') {
|
||||
if (/['";&|<>`^#$%!*?()[\]{}]/.test(command[index])) return null;
|
||||
index += 1;
|
||||
}
|
||||
if (index === start) return null;
|
||||
tokens.push(command.slice(start, index));
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function isAbsolutePathForPlatform(platform: NodeJS.Platform, candidate: string): boolean {
|
||||
return platform === 'win32'
|
||||
? pathWin32.isAbsolute(candidate)
|
||||
: pathPosix.isAbsolute(candidate);
|
||||
}
|
||||
|
||||
function normalizedHookPath(platform: NodeJS.Platform, candidate: string): string {
|
||||
const normalized = platform === 'win32' ? candidate.replace(/\\/g, '/') : candidate;
|
||||
return platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function isNodeExecutable(platform: NodeJS.Platform, candidate: string): boolean {
|
||||
const normalized = platform === 'win32' ? candidate.toLowerCase() : candidate;
|
||||
const allowedBasenames = platform === 'win32' ? ['node', 'node.exe'] : ['node'];
|
||||
if (allowedBasenames.includes(normalized)) return true;
|
||||
if (!isAbsolutePathForPlatform(platform, candidate)) return false;
|
||||
const basename = platform === 'win32'
|
||||
? pathWin32.basename(candidate).toLowerCase()
|
||||
: pathPosix.basename(candidate);
|
||||
return allowedBasenames.includes(basename);
|
||||
}
|
||||
|
||||
function isClaudeCodeHookCommand(
|
||||
command: string,
|
||||
basename: string,
|
||||
platform: NodeJS.Platform,
|
||||
): boolean {
|
||||
const tokens = tokenizeHookCommand(command);
|
||||
if (!tokens || (tokens.length !== 2 && tokens.length !== 4)) return false;
|
||||
if (!isNodeExecutable(platform, tokens[0])) return false;
|
||||
if (!isAbsolutePathForPlatform(platform, tokens[1])) return false;
|
||||
if (tokens.length === 4) {
|
||||
if (tokens[2] !== '--cli-path' || !isAbsolutePathForPlatform(platform, tokens[3])) return false;
|
||||
}
|
||||
|
||||
const scriptPath = normalizedHookPath(platform, tokens[1]);
|
||||
const expected = `/hive-mind-hooks-claude-code/dist/hooks/${basename}.js`;
|
||||
const normalizedExpected = platform === 'win32' ? expected.toLowerCase() : expected;
|
||||
return scriptPath.endsWith(normalizedExpected);
|
||||
}
|
||||
|
||||
function hasActiveClaudeCodeHooks(settings: unknown, platform: NodeJS.Platform): boolean {
|
||||
const settingsRecord = objectRecord(settings);
|
||||
const hooks = objectRecord(settingsRecord?.hooks);
|
||||
if (!hooks) return false;
|
||||
|
||||
return CLAUDE_CODE_HOOKS.every(([eventName, basename]) => {
|
||||
const groups = hooks[eventName];
|
||||
if (!Array.isArray(groups)) return false;
|
||||
return groups.some((group) => {
|
||||
const groupRecord = objectRecord(group);
|
||||
if (!groupRecord) return false;
|
||||
const entries = groupRecord.hooks;
|
||||
if (!Array.isArray(entries)) return false;
|
||||
return entries.some((entry) => {
|
||||
const entryRecord = objectRecord(entry);
|
||||
const command = entryRecord?.command;
|
||||
return entryRecord?.type === 'command'
|
||||
&& typeof command === 'string'
|
||||
&& isClaudeCodeHookCommand(command, basename, platform);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function activeClaudeCodeHooksHealthy(deps: ResolvedDeps): Promise<boolean> {
|
||||
const settingsPath = joinForPlatform(deps.platform, deps.home, '.claude', 'settings.json');
|
||||
if (!(await deps.exists(settingsPath))) return false;
|
||||
return hasActiveClaudeCodeHooks(await deps.readJson(settingsPath), deps.platform);
|
||||
}
|
||||
|
||||
async function activeHooksHealthy(
|
||||
pointerHealthy: boolean,
|
||||
toolId: string | undefined,
|
||||
deps: ResolvedDeps,
|
||||
): Promise<boolean> {
|
||||
if (!pointerHealthy) return false;
|
||||
return toolId !== 'claude-code' || activeClaudeCodeHooksHealthy(deps);
|
||||
}
|
||||
|
||||
function nonBlankEnv(deps: ResolvedDeps, name: string): string | null {
|
||||
const value = deps.env[name]?.trim();
|
||||
return value ? value : null;
|
||||
}
|
||||
|
||||
function localAppDataRoot(deps: ResolvedDeps): string {
|
||||
return nonBlankEnv(deps, 'LOCALAPPDATA')
|
||||
?? joinForPlatform(deps.platform, deps.home, 'AppData', 'Local');
|
||||
}
|
||||
|
||||
function hermesHome(deps: ResolvedDeps): string {
|
||||
const configured = nonBlankEnv(deps, 'HERMES_HOME');
|
||||
if (configured) {
|
||||
return deps.platform === 'win32'
|
||||
? pathWin32.normalize(configured)
|
||||
: pathPosix.normalize(configured);
|
||||
}
|
||||
return deps.platform === 'win32'
|
||||
? joinForPlatform(deps.platform, localAppDataRoot(deps), 'hermes')
|
||||
: joinForPlatform(deps.platform, deps.home, '.hermes');
|
||||
}
|
||||
|
||||
async function probeHooks(
|
||||
rel: string,
|
||||
deps: ResolvedDeps,
|
||||
hookRoot: ToolManifest['hookRoot'] = 'user-home',
|
||||
toolId?: string,
|
||||
): Promise<HookProbe> {
|
||||
if (!rel) return { hooksInstalled: false, hookPointerPath: null };
|
||||
const pointerPath = joinForPlatform(deps.platform, deps.home, rel);
|
||||
const root = hookRoot === 'hermes-home' ? hermesHome(deps) : deps.home;
|
||||
const pointerPath = joinForPlatform(deps.platform, root, rel);
|
||||
if (!(await deps.exists(pointerPath))) {
|
||||
return { hooksInstalled: false, hookPointerPath: null };
|
||||
}
|
||||
@@ -263,13 +527,20 @@ async function probeHooks(rel: string, deps: ResolvedDeps): Promise<HookProbe> {
|
||||
: typeof hooksDir === 'string' && hooksDir.length > 0 && await deps.exists(hooksDir);
|
||||
if (!hooksDirValid) return { hooksInstalled: false, hookPointerPath: pointerPath };
|
||||
if (typeof backup === 'string' && backup.length > 0) {
|
||||
return { hooksInstalled: await deps.exists(backup), hookPointerPath: pointerPath };
|
||||
return {
|
||||
hooksInstalled: await activeHooksHealthy(await deps.exists(backup), toolId, deps),
|
||||
hookPointerPath: pointerPath,
|
||||
};
|
||||
}
|
||||
// Create-if-missing adapters correctly have no backup. Their pointer is
|
||||
// healthy only while the config they created still exists.
|
||||
if (backup === null && pointer.created_by_us === true && typeof pointer.config_path === 'string') {
|
||||
return {
|
||||
hooksInstalled: await deps.exists(pointer.config_path),
|
||||
hooksInstalled: await activeHooksHealthy(
|
||||
await deps.exists(pointer.config_path),
|
||||
toolId,
|
||||
deps,
|
||||
),
|
||||
hookPointerPath: pointerPath,
|
||||
};
|
||||
}
|
||||
@@ -293,6 +564,7 @@ async function detectByPath(
|
||||
deps: ResolvedDeps,
|
||||
hookPointer: string,
|
||||
displayName: string,
|
||||
hookRoot?: ToolManifest['hookRoot'],
|
||||
): Promise<DetectedTool> {
|
||||
const base: DetectedTool = {
|
||||
id,
|
||||
@@ -304,12 +576,12 @@ async function detectByPath(
|
||||
hookPointerPath: null,
|
||||
};
|
||||
const resolved = await deps.pathFromEnv(binaryName);
|
||||
if (!resolved) return { ...base, ...(await probeHooks(hookPointer, deps)) };
|
||||
if (!resolved) return { ...base, ...(await probeHooks(hookPointer, deps, hookRoot, id)) };
|
||||
if (!(await deps.exists(resolved))) {
|
||||
return { ...base, ...(await probeHooks(hookPointer, deps)) };
|
||||
return { ...base, ...(await probeHooks(hookPointer, deps, hookRoot, id)) };
|
||||
}
|
||||
const versionRaw = await deps.execVersion(resolved, ['--version']);
|
||||
const hookProbe = await probeHooks(hookPointer, deps);
|
||||
const hookProbe = await probeHooks(hookPointer, deps, hookRoot, id);
|
||||
const blockedWindowsAppsCodex =
|
||||
versionRaw === null && isBlockedWindowsAppsCodexPath(id, deps.platform, resolved);
|
||||
return {
|
||||
@@ -325,6 +597,57 @@ async function detectByPath(
|
||||
};
|
||||
}
|
||||
|
||||
function hermesWindowsCandidatePaths(deps: ResolvedDeps): string[] {
|
||||
const base = hermesHome(deps);
|
||||
return [
|
||||
joinForPlatform(deps.platform, base, 'bin', 'hermes.cmd'),
|
||||
joinForPlatform(deps.platform, base, 'hermes-agent', 'venv', 'Scripts', 'hermes.exe'),
|
||||
joinForPlatform(deps.platform, base, 'hermes-agent', 'venv', 'Scripts', 'hermes-agent.exe'),
|
||||
];
|
||||
}
|
||||
|
||||
async function detectHealthyWindowsHermes(
|
||||
binaryName: string,
|
||||
deps: ResolvedDeps,
|
||||
hookPointer: string,
|
||||
displayName: string,
|
||||
hookRoot?: ToolManifest['hookRoot'],
|
||||
): Promise<DetectedTool> {
|
||||
const pathCandidate = await deps.pathFromEnv(binaryName);
|
||||
const candidates = [pathCandidate, ...hermesWindowsCandidatePaths(deps)]
|
||||
.filter((candidate): candidate is string => Boolean(candidate));
|
||||
const uniqueCandidates = candidates.filter((candidate, index) =>
|
||||
candidates.findIndex((value) => value.toLowerCase() === candidate.toLowerCase()) === index);
|
||||
let firstExisting: string | null = null;
|
||||
|
||||
for (const candidate of uniqueCandidates) {
|
||||
if (!(await deps.exists(candidate))) continue;
|
||||
firstExisting ??= candidate;
|
||||
const version = await deps.execVersion(candidate, ['--version']);
|
||||
if (version) {
|
||||
return {
|
||||
id: 'hermes',
|
||||
displayName,
|
||||
installed: true,
|
||||
installedPath: candidate,
|
||||
version,
|
||||
...(await probeHooks(hookPointer, deps, hookRoot, 'hermes')),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'hermes',
|
||||
displayName,
|
||||
installed: firstExisting !== null,
|
||||
installedPath: firstExisting,
|
||||
version: null,
|
||||
launchable: firstExisting ? false : undefined,
|
||||
diagnostic: firstExisting ? HERMES_WINDOWS_HEALTH_DIAGNOSTIC : undefined,
|
||||
...(await probeHooks(hookPointer, deps, hookRoot, 'hermes')),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Candidate-path helpers (per platform) ───────────────────────────
|
||||
|
||||
function cursorCandidatePaths(deps: ResolvedDeps): string[] {
|
||||
@@ -345,9 +668,11 @@ function cursorCandidatePaths(deps: ResolvedDeps): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
function claudeDesktopCandidatePaths(deps: ResolvedDeps): string[] {
|
||||
async function claudeDesktopCandidatePaths(deps: ResolvedDeps): Promise<string[]> {
|
||||
if (deps.platform === 'win32') {
|
||||
const registered = await deps.windowsAppExecutables();
|
||||
return [
|
||||
...(registered['claude-desktop'] ?? []),
|
||||
joinForPlatform(deps.platform, deps.home, 'AppData', 'Local', 'AnthropicClaude', 'Claude.exe'),
|
||||
'C:\\Program Files\\AnthropicClaude\\Claude.exe',
|
||||
];
|
||||
@@ -364,13 +689,45 @@ function claudeDesktopCandidatePaths(deps: ResolvedDeps): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
function codexDesktopCandidatePaths(deps: ResolvedDeps): string[] {
|
||||
// OpenAI Codex Desktop is unreleased at time of writing (May 2026)
|
||||
// but the hook package already targets it. Use the conventional
|
||||
// per-platform vendor paths so a future official install is
|
||||
// detected automatically.
|
||||
function hermesDesktopCandidatePaths(deps: ResolvedDeps): string[] {
|
||||
if (deps.platform === 'win32') {
|
||||
const localAppData = localAppDataRoot(deps);
|
||||
return [
|
||||
joinForPlatform(
|
||||
deps.platform,
|
||||
hermesHome(deps),
|
||||
'hermes-agent',
|
||||
'apps',
|
||||
'desktop',
|
||||
'release',
|
||||
'win-unpacked',
|
||||
'Hermes.exe',
|
||||
),
|
||||
joinForPlatform(deps.platform, localAppData, 'Programs', 'Hermes', 'Hermes.exe'),
|
||||
joinForPlatform(deps.platform, localAppData, 'Programs', 'hermes', 'Hermes.exe'),
|
||||
'C:\\Program Files\\Hermes\\Hermes.exe',
|
||||
];
|
||||
}
|
||||
if (deps.platform === 'darwin') {
|
||||
return ['/Applications/Hermes.app/Contents/MacOS/Hermes'];
|
||||
}
|
||||
return [
|
||||
joinForPlatform(deps.platform, deps.home, '.local', 'share', 'Hermes', 'Hermes'),
|
||||
'/opt/Hermes/Hermes',
|
||||
];
|
||||
}
|
||||
|
||||
async function codexDesktopCandidatePaths(deps: ResolvedDeps): Promise<string[]> {
|
||||
if (deps.platform === 'win32') {
|
||||
const registered = await deps.windowsAppExecutables();
|
||||
const codexPath = await deps.pathFromEnv('codex');
|
||||
const normalized = codexPath?.replace(/\//g, '\\') ?? '';
|
||||
const storeDesktopPath = /\\WindowsApps\\OpenAI\.Codex_[^\\]+\\app\\resources\\codex(?:\.exe)?$/i.test(normalized)
|
||||
? pathWin32.join(pathWin32.dirname(pathWin32.dirname(normalized)), 'ChatGPT.exe')
|
||||
: null;
|
||||
return [
|
||||
...(registered['codex-desktop'] ?? []),
|
||||
...(storeDesktopPath ? [storeDesktopPath] : []),
|
||||
joinForPlatform(deps.platform, deps.home, 'AppData', 'Local', 'OpenAI', 'Codex.exe'),
|
||||
'C:\\Program Files\\OpenAI\\Codex.exe',
|
||||
];
|
||||
@@ -395,6 +752,7 @@ async function detectByCandidates(
|
||||
withVersion: boolean,
|
||||
hookPointer: string,
|
||||
displayName: string,
|
||||
hookRoot?: ToolManifest['hookRoot'],
|
||||
): Promise<DetectedTool> {
|
||||
const base: DetectedTool = {
|
||||
id,
|
||||
@@ -410,7 +768,7 @@ async function detectByCandidates(
|
||||
const versionRaw = withVersion
|
||||
? await deps.execVersion(candidate, ['--version'])
|
||||
: null;
|
||||
const hookProbe = await probeHooks(hookPointer, deps);
|
||||
const hookProbe = await probeHooks(hookPointer, deps, hookRoot, id);
|
||||
return {
|
||||
...base,
|
||||
installed: true,
|
||||
@@ -422,7 +780,7 @@ async function detectByCandidates(
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ...base, ...(await probeHooks(hookPointer, deps)) };
|
||||
return { ...base, ...(await probeHooks(hookPointer, deps, hookRoot, id)) };
|
||||
}
|
||||
|
||||
// ── Registry-driven detection ───────────────────────────────────────
|
||||
@@ -432,15 +790,17 @@ async function detectByCandidates(
|
||||
* whose paths are platform-branching code, not declarative data). Keyed by
|
||||
* built-in id; third-party adapters are PATH-only so never need an entry.
|
||||
*/
|
||||
const CANDIDATE_RESOLVERS: Record<string, (deps: ResolvedDeps) => string[]> = {
|
||||
const CANDIDATE_RESOLVERS: Record<string, (deps: ResolvedDeps) => string[] | Promise<string[]>> = {
|
||||
'cursor': cursorCandidatePaths,
|
||||
'claude-desktop': claudeDesktopCandidatePaths,
|
||||
'codex-desktop': codexDesktopCandidatePaths,
|
||||
'hermes-desktop': hermesDesktopCandidatePaths,
|
||||
};
|
||||
|
||||
function withManifestMetadata(tool: DetectedTool, manifest: ToolManifest): DetectedTool {
|
||||
return {
|
||||
...tool,
|
||||
releaseStatus: manifest.releaseStatus,
|
||||
launchable: tool.launchable ?? manifest.launchable,
|
||||
hookCapable: manifest.hookCapable,
|
||||
builtin: manifest.builtin === true,
|
||||
@@ -453,15 +813,35 @@ function withManifestMetadata(tool: DetectedTool, manifest: ToolManifest): Detec
|
||||
/** Detect one tool from its manifest: PATH lookup, or the candidate resolver. */
|
||||
async function detectFromManifest(m: ToolManifest, deps: ResolvedDeps): Promise<DetectedTool> {
|
||||
if (m.detect.kind === 'path') {
|
||||
if (m.id === 'hermes' && deps.platform === 'win32') {
|
||||
return withManifestMetadata(
|
||||
await detectHealthyWindowsHermes(
|
||||
m.detect.binaryName,
|
||||
deps,
|
||||
m.hookPointer,
|
||||
m.displayName,
|
||||
m.hookRoot,
|
||||
),
|
||||
m,
|
||||
);
|
||||
}
|
||||
return withManifestMetadata(
|
||||
await detectByPath(m.id, m.detect.binaryName, deps, m.hookPointer, m.displayName),
|
||||
await detectByPath(m.id, m.detect.binaryName, deps, m.hookPointer, m.displayName, m.hookRoot),
|
||||
m,
|
||||
);
|
||||
}
|
||||
const resolver = CANDIDATE_RESOLVERS[m.id];
|
||||
const candidates = resolver ? resolver(deps) : [];
|
||||
const candidates = resolver ? await resolver(deps) : [];
|
||||
return withManifestMetadata(
|
||||
await detectByCandidates(m.id, candidates, deps, /* withVersion */ false, m.hookPointer, m.displayName),
|
||||
await detectByCandidates(
|
||||
m.id,
|
||||
candidates,
|
||||
deps,
|
||||
/* withVersion */ false,
|
||||
m.hookPointer,
|
||||
m.displayName,
|
||||
m.hookRoot,
|
||||
),
|
||||
m,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,34 +10,46 @@
|
||||
* 2. onToolUse callback
|
||||
* 3. Governance.blockedTools — early return on block (fires onToolResult)
|
||||
* 4. pre:tool hook — early return on cancel
|
||||
* 4b. critical-destructive hard floor — deny isCriticalNeverAutopass ops that
|
||||
* reach here without an approval gate (defense-in-depth; independent of hooks)
|
||||
* 4b. state-change approval floor — deny confirmation-required ops that reach
|
||||
* here without explicit authorization (defense-in-depth; independent of hooks)
|
||||
* 5. pre:memory-write hook (save_memory only) — early return on cancel
|
||||
* 6. LoopGuard.check — produces error result if duplicate
|
||||
* 7. Execute (or capability-router fallback or unknown-tool error)
|
||||
* 8. scanForInjection — REVIEW C2: BEFORE onToolResult / post-hooks
|
||||
* 8. evaluateExternalMemoryIngress — REVIEW C2: BEFORE onToolResult / post-hooks
|
||||
* 9. onToolResult callback (sanitized content)
|
||||
* 10. post:memory-write hook (save_memory only, sanitized)
|
||||
* 11. post:tool hook (sanitized)
|
||||
* 12. compress model-facing result (subtractive; observers keep full fidelity)
|
||||
*
|
||||
* Critical invariant (Review C2): steps 8 → 9 → 10 → 11 must stay in this
|
||||
* order. Sanitization output is what flows into both model context AND
|
||||
* order. Canonically guarded output is what flows into both model context AND
|
||||
* every downstream observer (audit / telemetry / team-sync / UI). Step 12 is
|
||||
* subtractive-only and applies ONLY to the returned (model-facing) content —
|
||||
* observers at 9–11 still receive the full sanitized result.
|
||||
* observers at 9–11 still receive the full guarded result.
|
||||
*/
|
||||
|
||||
import type { ToolDefinition } from './tools.js';
|
||||
import type { HookRegistry } from './hooks.js';
|
||||
import type { CapabilityRouter } from './capability-router.js';
|
||||
import type { LoopGuard } from './loop-guard.js';
|
||||
import { scanForInjection } from './injection-scanner.js';
|
||||
import { isCriticalNeverAutopass } from './confirmation.js';
|
||||
import { evaluateExternalMemoryIngress } from '@waggle/core';
|
||||
import { isCriticalNeverAutopass, needsConfirmation } from './confirmation.js';
|
||||
import { compressToolOutput } from './tool-output-compressor.js';
|
||||
import { logTurnEvent } from './turn-context.js';
|
||||
import { untrustedContextWrapper } from './untrusted-context.js';
|
||||
|
||||
const QUARANTINED_TOOL_OUTPUT = '[SECURITY] Tool output quarantined.';
|
||||
|
||||
/**
|
||||
* Never reflect rejected external content (or guard details) beyond this boundary.
|
||||
* The canonical ingress guard includes legacy scanning plus normalization-aware checks.
|
||||
*/
|
||||
function guardExternalToolOutput(result: string): string {
|
||||
return evaluateExternalMemoryIngress({ content: result }).action === 'allow'
|
||||
? result
|
||||
: QUARANTINED_TOOL_OUTPUT;
|
||||
}
|
||||
|
||||
export interface ToolExecutorDeps {
|
||||
toolMap: ReadonlyMap<string, ToolDefinition>;
|
||||
guard: LoopGuard;
|
||||
@@ -135,17 +147,19 @@ export async function executeToolCall(
|
||||
result = `Error: Unknown tool "${fnName}". Available tools: ${Array.from(toolMap.keys()).join(', ')}`;
|
||||
}
|
||||
|
||||
const scanResult = scanForInjection(result, 'tool_output');
|
||||
if (!scanResult.safe) {
|
||||
result = `[SECURITY] Tool output flagged (${scanResult.flags.join(', ')}). Content sanitized.`;
|
||||
}
|
||||
result = guardExternalToolOutput(result);
|
||||
if (onToolResult) onToolResult(fnName, fnArgs, result);
|
||||
return { content: result, toolCallId: toolCall.id, countedAsUsed: false, toolName: fnName };
|
||||
}
|
||||
|
||||
// ── Step 4: pre:tool hook ──
|
||||
let approvedByHook = false;
|
||||
if (hooks) {
|
||||
const hookResult = await hooks.fire('pre:tool', { toolName: fnName, args: fnArgs });
|
||||
const hookResult = await hooks.fire('pre:tool', {
|
||||
toolName: fnName,
|
||||
args: fnArgs,
|
||||
riskLevel: existingTool.riskLevel,
|
||||
});
|
||||
if (hookResult.cancelled) {
|
||||
return {
|
||||
content: `[BLOCKED] ${hookResult.reason ?? 'No reason given'}`,
|
||||
@@ -154,33 +168,33 @@ export async function executeToolCall(
|
||||
toolName: fnName,
|
||||
};
|
||||
}
|
||||
approvedByHook = hookResult.authorized === true;
|
||||
}
|
||||
|
||||
// ── Step 4b: critical-destructive hard floor (defense-in-depth) ──
|
||||
// isCriticalNeverAutopass flags terminal, irreversible operations that must
|
||||
// pass a human/policy approval gate at EVERY layer — not only the main chat
|
||||
// loop. The main loop gates them via the pre:tool hook fired above; spawn
|
||||
// paths (sub-agent / workflow / worker) that forward that same hook registry
|
||||
// inherit the gate. If NO approval mechanism reached this call, fail closed:
|
||||
// deny rather than silently execute. This runs unconditionally — it does not
|
||||
// depend on the pre:tool hook being wired, which is the whole point. Without
|
||||
// it, a spawn path constructed with `hooks: undefined` executed rm -rf ~,
|
||||
// sudo, git push --force main, delete_skill, etc. unconfirmed.
|
||||
if (isCriticalNeverAutopass(fnName, fnArgs)) {
|
||||
// ── Step 4b: state-change approval floor (defense-in-depth) ──
|
||||
// Every confirmation-required operation must carry an explicit authorization
|
||||
// across this final execution boundary. Interactive chat supplies it through
|
||||
// its request-local pre:tool hook; saved grants and elevated autonomy do the
|
||||
// same after their policy checks. Background paths without an approval
|
||||
// provider therefore fail closed instead of silently mutating state.
|
||||
const critical = isCriticalNeverAutopass(fnName, fnArgs, existingTool.riskLevel);
|
||||
if (critical || needsConfirmation(fnName, fnArgs, existingTool.riskLevel)) {
|
||||
const approvedOutOfBand = confirmCriticalAction
|
||||
&& critical
|
||||
? await confirmCriticalAction(fnName, fnArgs)
|
||||
: false;
|
||||
// A pre:tool approval gate present at step 4 already vetted this call (a
|
||||
// critical op always trips needsConfirmationWithAutonomy, so reaching here
|
||||
// past a non-cancelled hook means it was approved). No callback and no gate
|
||||
// ⇒ no human in the loop ⇒ deny.
|
||||
const gatedByHook = hooks !== undefined;
|
||||
if (!approvedOutOfBand && !gatedByHook) {
|
||||
const denyMsg =
|
||||
`[BLOCKED] "${fnName}" is a critical, irreversible operation that requires ` +
|
||||
`explicit human approval. It was denied because this execution context ` +
|
||||
`(such as a sub-agent or automated workflow) has no approval gate. ` +
|
||||
`Terminal-destructive commands never run unconfirmed.`;
|
||||
// Only an explicit successful hook authorization or approval callback can
|
||||
// cross the hard floor. Registry presence or a swallowed hook error is not
|
||||
// proof that a human or policy gate approved the call.
|
||||
if (!approvedOutOfBand && !approvedByHook) {
|
||||
const denyMsg = critical
|
||||
? `[BLOCKED] "${fnName}" is a critical, irreversible operation that requires ` +
|
||||
`explicit human approval. It was denied because this execution context ` +
|
||||
`(such as a sub-agent or automated workflow) has no approval gate. ` +
|
||||
`Terminal-destructive commands never run unconfirmed.`
|
||||
: `[BLOCKED] "${fnName}" changes state and requires explicit approval. ` +
|
||||
`It was denied because this execution context (such as a sub-agent or ` +
|
||||
`automated workflow) did not provide an authorization decision.`;
|
||||
if (onToolResult) onToolResult(fnName, fnArgs, denyMsg);
|
||||
return { content: denyMsg, toolCallId: toolCall.id, countedAsUsed: false, toolName: fnName };
|
||||
}
|
||||
@@ -234,16 +248,17 @@ export async function executeToolCall(
|
||||
} else if (tool) {
|
||||
logTurnEvent(turnId, { stage: 'agent-loop.tool.enter', toolName: fnName, argsKeys: Object.keys(fnArgs) });
|
||||
try {
|
||||
result = await tool.execute(fnArgs);
|
||||
const rawResult = await tool.execute(fnArgs);
|
||||
// Tools in this codebase report many failures by RETURNING an
|
||||
// "Error: ..." string rather than throwing — count those as failures
|
||||
// too, or the failure tiers never see them.
|
||||
guard.record(fnName, fnArgs, !/^Error\b/.test(result));
|
||||
guard.record(fnName, fnArgs, !/^Error\b/.test(rawResult));
|
||||
result = guardExternalToolOutput(rawResult);
|
||||
logTurnEvent(turnId, { stage: 'agent-loop.tool.exit', toolName: fnName, resultChars: result.length, error: false });
|
||||
} catch (err) {
|
||||
result = `Error executing ${fnName}: ${(err as Error).message}`;
|
||||
result = guardExternalToolOutput(`Error executing ${fnName}: ${(err as Error).message}`);
|
||||
guard.record(fnName, fnArgs, false);
|
||||
logTurnEvent(turnId, { stage: 'agent-loop.tool.exit', toolName: fnName, error: true, errorMessage: (err as Error).message });
|
||||
logTurnEvent(turnId, { stage: 'agent-loop.tool.exit', toolName: fnName, error: true, errorMessage: result });
|
||||
}
|
||||
countedAsUsed = true;
|
||||
} else if (capabilityRouter) {
|
||||
@@ -261,16 +276,14 @@ export async function executeToolCall(
|
||||
result = `Error: Unknown tool "${fnName}". Available tools: ${Array.from(toolMap.keys()).join(', ')}`;
|
||||
}
|
||||
|
||||
// ── Step 8: sanitize BEFORE post-hooks + onToolResult (Review C2) ──
|
||||
// The scanner output is what flows into both model context on the next
|
||||
// turn AND into every downstream observer (audit sinks, telemetry,
|
||||
// team-sync, UI). Order is load-bearing — do not reorder.
|
||||
const scanResult = scanForInjection(result, 'tool_output');
|
||||
if (!scanResult.safe) {
|
||||
result = `[SECURITY] Tool output flagged (${scanResult.flags.join(', ')}). Content sanitized.`;
|
||||
}
|
||||
// ── Step 8: canonical guard BEFORE post-hooks + onToolResult (Review C2) ──
|
||||
// The guarded output is what flows into both model context on the next turn
|
||||
// AND every downstream observer (audit sinks, telemetry, team-sync, UI).
|
||||
// Non-allows become an opaque marker; guard details and normalized attacker
|
||||
// content must never cross this boundary. Order is load-bearing.
|
||||
result = guardExternalToolOutput(result);
|
||||
|
||||
// ── Step 9: onToolResult callback (sanitized content) ──
|
||||
// ── Step 9: onToolResult callback (guarded content) ──
|
||||
if (onToolResult) onToolResult(fnName, fnArgs, result);
|
||||
|
||||
// ── Step 10: post:memory-write hook (save_memory only, sanitized) ──
|
||||
|
||||
@@ -78,3 +78,504 @@ export function filterOfflineTools(tools: ToolDefinition[]): ToolDefinition[] {
|
||||
export function getOfflineCapableToolNames(tools: ToolDefinition[]): string[] {
|
||||
return tools.filter(t => t.offlineCapable === true).map(t => t.name);
|
||||
}
|
||||
|
||||
export const DEFAULT_TURN_TOOL_LIMIT = 14;
|
||||
export const DEFAULT_TURN_SCHEMA_CHAR_LIMIT = 8_000;
|
||||
|
||||
export interface TurnToolSelectionOptions {
|
||||
message: string;
|
||||
recentMessages?: readonly { role: string; content: string }[];
|
||||
preferredToolNames?: readonly string[];
|
||||
recentToolNames?: readonly string[];
|
||||
mandatoryToolNames?: readonly string[];
|
||||
externalToolNames?: readonly string[];
|
||||
/** External tools matched by semantic retrieval for this turn. */
|
||||
retrievedToolNames?: readonly string[];
|
||||
maxTools?: number;
|
||||
maxSchemaChars?: number;
|
||||
/** Keep a bounded authorized pool for delegated tasks with terse instructions. */
|
||||
fallbackToEligible?: boolean;
|
||||
}
|
||||
|
||||
export interface TurnToolSelectionResult {
|
||||
tools: ToolDefinition[];
|
||||
schemaChars: number;
|
||||
omittedCount: number;
|
||||
}
|
||||
|
||||
interface IntentBundle {
|
||||
pattern: RegExp;
|
||||
tools: readonly string[];
|
||||
}
|
||||
|
||||
const ACTION_PATTERN = /\b(use|using|call|invoke|create|build|draft|write|read|edit|modify|make|generate|export|download|analy[sz]e|research|investigate|find|search|look up|run|execute|fix|debug|test|validate|verify|inspect|review|prepare|plan|schedule|remind|send|post|commit|push|pull|merge|delegate|coordinate|orchestrate|browse|navigate|open|click|fill|remember|recall|save|calculate|model|transform|query|design|implement|compile|lint|refactor|summarize|check)\b/i;
|
||||
const CONTINUATION_PATTERN = /^\s*(?:(?:yes,?\s+please)\b|(?:(?:(?:ok(?:ay)?|yes)[,\s]+)?(?:(?:please\s+)|(?:(?:can|could|would|will)\s+you\s+(?:please\s+)?))?(?:continue|proceed|do\s+it|go\s+ahead|next\s+step|carry\s+on)\b))/i;
|
||||
const RETRY_CONTINUATION_PATTERN = /^\s*(?:(?:ok(?:ay)?|yes)[,\s]+)?(?:(?:please\s+)|(?:(?:can|could|would|will)\s+you\s+(?:please\s+)?))?(?:try\s+(?:now|again)|retry|same\s+again)\b/i;
|
||||
const TOOL_RETRY_CONTEXT_PATTERN = /\b(?:no tools? (?:are|were) serialized|tool access (?:was|is) unavailable|nothing for me to run|could not use (?:the )?tools?|couldn['\u2019]t use (?:the )?tools?)\b/i;
|
||||
const DIRECTIVE_BOUNDARY_SOURCE = String.raw`(?:^|[.;:!?\r\n]\s*|\b(?:and|but|then)\s+)`;
|
||||
const MUTATION_DIRECTIVE_BOUNDARY_SOURCE = String.raw`(?:^|[.;!?]\s*|\b(?:and|but|then)\s+)`;
|
||||
const DIRECTIVE_LEAD_SOURCE = String.raw`(?:(?:please(?:,\s*|\s+))|(?:(?:can|could|would|will)\s+you\s+(?:please(?:,\s*|\s+))?(?:(?:be\s+able\s+to\s+)|(?:help\s+(?:me|us)\s+(?:to\s+)?)))|(?:(?:can|could|would|will)\s+(?:you|we)\s+(?:please(?:,\s*|\s+))?)|(?:i\s+(?:need|want|would\s+like)\s+you\s+to\s+)|(?:(?:please(?:,\s*|\s+))?go\s+ahead\s+and\s+)|(?:let(?:['\u2019]s|\s+us)\s+))?`;
|
||||
const DIRECT_ACTION_DIRECTIVE_PATTERN = new RegExp(
|
||||
String.raw`^\s*${DIRECTIVE_LEAD_SOURCE}${ACTION_PATTERN.source}`,
|
||||
'i',
|
||||
);
|
||||
const REPOSITORY_DISCOVERY_PATTERN = new RegExp(
|
||||
String.raw`${DIRECTIVE_BOUNDARY_SOURCE}${DIRECTIVE_LEAD_SOURCE}(?:(?:explore|examine|understand|(?:take\s+a\s+)?look\s+(?:through|at))\b[^.;!?\r\n]*\b(?:repo(?:sitory)?|codebase|code|project|workspace)\b|inspect\b[^.;!?\r\n]*\b(?:repo(?:sitory)?|codebase|workspace)\b)`,
|
||||
'i',
|
||||
);
|
||||
const DIRECT_REPOSITORY_DISCOVERY_PATTERN = new RegExp(
|
||||
String.raw`^\s*${DIRECTIVE_LEAD_SOURCE}(?:(?:explore|examine|understand|(?:take\s+a\s+)?look\s+(?:through|at))\b[^.;!?\r\n]*\b(?:repo(?:sitory)?|codebase|code|project|workspace)\b|inspect\b[^.;!?\r\n]*\b(?:repo(?:sitory)?|codebase|workspace)\b)`,
|
||||
'i',
|
||||
);
|
||||
const REPOSITORY_EXECUTION_OR_MUTATION_VERB_SOURCE = String.raw`(?:run|execute|test|fix|debug|edit|modify|write|create|implement|compile|lint|refactor|commit|push|pull|merge|delete|remove)`;
|
||||
const EXECUTION_DIRECTIVE_BOUNDARY_SOURCE = String.raw`(?:^|[.;!?]\s*)`;
|
||||
const REPOSITORY_CONTINUATION_SOURCE = String.raw`(?:,\s*(?:then\s+)?|\s+(?:and|but)(?:\s+then)?\s+|\s+then\s+)`;
|
||||
const REPOSITORY_EXECUTION_OR_MUTATION_PATTERN = new RegExp(
|
||||
String.raw`(?:${EXECUTION_DIRECTIVE_BOUNDARY_SOURCE}(?:then\s+)?${DIRECTIVE_LEAD_SOURCE}${REPOSITORY_EXECUTION_OR_MUTATION_VERB_SOURCE}\b|${DIRECT_REPOSITORY_DISCOVERY_PATTERN.source}\s+to\s+${REPOSITORY_EXECUTION_OR_MUTATION_VERB_SOURCE}\b|${DIRECT_REPOSITORY_DISCOVERY_PATTERN.source}${REPOSITORY_CONTINUATION_SOURCE}${DIRECTIVE_LEAD_SOURCE}${REPOSITORY_EXECUTION_OR_MUTATION_VERB_SOURCE}\b|${EXECUTION_DIRECTIVE_BOUNDARY_SOURCE}(?:then\s+)?${DIRECTIVE_LEAD_SOURCE}(?:use|using)\s+(?:bash|terminal|shell)\b|${DIRECT_REPOSITORY_DISCOVERY_PATTERN.source}${REPOSITORY_CONTINUATION_SOURCE}${DIRECTIVE_LEAD_SOURCE}(?:use|using)\s+(?:bash|terminal|shell)\b)`,
|
||||
'i',
|
||||
);
|
||||
const NEGATED_TOOL_VERB_SOURCE = String.raw`(?:use|using|call|calling|invoke|invoking|create|creating|write|writing|edit|editing|read|reading|browse|browsing|explore|exploring|search|searching|schedule|scheduling|send|sending|post|posting|commit|committing|push|pushing|delete|deleting|remove|removing|run|running|execute|executing|try|trying|retry|retrying)`;
|
||||
const NEGATED_TOOL_DIRECTIVE_SOURCE = String.raw`(?:do\s+not|don['\u2019]t|(?:do\s+not|don['\u2019]t)\s+want\s+to|never|must\s+not|mustn['\u2019]t|should\s+not|shouldn['\u2019]t|may\s+not|might\s+not|cannot|can\s+not|can['\u2019]t|will\s+not|won['\u2019]t|would\s+not|wouldn['\u2019]t|(?:am|are|is|['\u2019](?:m|re|s))\s+not(?:\s+(?:ready(?:\s+to)?|able\s+to|allowed\s+to|going\s+to))?|(?:aren['\u2019]t|isn['\u2019]t)\s+(?:ready(?:\s+to)?|able\s+to|allowed\s+to|going\s+to)|there\s+(?:is|['\u2019]s)\s+no\s+need\s+to|not(?:\s+(?:ready(?:\s+to)?|able\s+to|allowed\s+to|going\s+to))?)`;
|
||||
const NEGATED_TOOL_NOUN_SOURCE = String.raw`(?:calculator(?:\s+(?:tool|plugin))?|tools?|files?|documents?|artifacts?|workbooks?|spreadsheets?|xlsx|code|python|scripts?)`;
|
||||
const POSITIVE_TOOL_CLAUSE_RESUME_SOURCE = String.raw`(?:\b(?:but|however|instead|then)\b|\band\s+(?=(?:please\s+)?(?:${ACTION_PATTERN.source}|\bexplore\b)))`;
|
||||
const NEGATED_TOOL_CLAUSE_PATTERN = new RegExp(
|
||||
String.raw`\b(?:(?:${NEGATED_TOOL_DIRECTIVE_SOURCE}\s+|without\s+)${NEGATED_TOOL_VERB_SOURCE}\b|without\s+(?:(?:the\s+)?use\s+of\s+)?(?:(?:an?|the|any)\s+)?${NEGATED_TOOL_NOUN_SOURCE}\b)(?:(?!${POSITIVE_TOOL_CLAUSE_RESUME_SOURCE})[^.;!?\r\n])*`,
|
||||
'giu',
|
||||
);
|
||||
const POST_VERBAL_NEGATIVE_COUNT_SOURCE = String.raw`(?:(?:no(?!\s+more\s+than\b)|zero|0|not\s+(?:one|a\s+single|any))\s+|(?:none|neither)(?:\s+of)?\s+(?:the\s+)?)`;
|
||||
const POST_VERBAL_NEGATED_TOOL_CLAUSE_PATTERN = new RegExp(
|
||||
String.raw`\b(?:(?:run|execute|test)\s+(?:${POST_VERBAL_NEGATIVE_COUNT_SOURCE}(?:tests?|commands?|scripts?|tasks?|checks?)\b|nothing(?!\s+but\b)|neither\b[^.;!?\r\n]*\bnor\b[^.;!?\r\n]*\b(?:tests?|commands?|scripts?|tasks?|checks?)\b)|(?:edit|modify|write|create|delete|remove)\s+(?:${POST_VERBAL_NEGATIVE_COUNT_SOURCE}(?:files?|documents?|artifacts?|changes?)\b|nothing(?!\s+but\b)|neither\b[^.;!?\r\n]*\bnor\b[^.;!?\r\n]*\b(?:files?|documents?|artifacts?|changes?)\b)|(?:commit|push|pull|merge)\s+(?:${POST_VERBAL_NEGATIVE_COUNT_SOURCE}(?:changes?|commits?|branches?|files?)\b|nothing(?!\s+but\b)|neither\b[^.;!?\r\n]*\bnor\b[^.;!?\r\n]*\b(?:changes?|commits?|branches?|files?)\b))[^.;!?\r\n]*`,
|
||||
'giu',
|
||||
);
|
||||
const LEADING_NEGATED_TOOL_CLAUSE_PATTERN = new RegExp(
|
||||
String.raw`^\s*(?:${NEGATED_TOOL_CLAUSE_PATTERN.source}|${POST_VERBAL_NEGATED_TOOL_CLAUSE_PATTERN.source})`,
|
||||
'iu',
|
||||
);
|
||||
const DIRECT_CALCULATION_PATTERN = /\b(?:calculate|compute)\b/i;
|
||||
const CALCULATION_RELATION_PATTERN = /\b(?:divided by|multiplied by|plus|minus|times|sum of|difference between|ratio of|percent(?:age)? of)\b/i;
|
||||
const RESEARCH_INTENT_PATTERN = /\b(research|investigate|find information|source|sources|citation|cite|current|latest|docs?|documentation|web|internet|online|benchmark)\b/i;
|
||||
const EXPLICIT_CALCULATION_CAPABILITY_PATTERN = /\b(?:create|build|draft|write|read|edit|modify|make|generate|export|download|analy[sz]e|research|investigate|find|search|look up|run|execute|fix|debug|test|validate|verify|inspect|review|prepare|plan|schedule|remind|send|post|commit|push|pull|merge|delegate|coordinate|orchestrate|browse|navigate|open|click|fill|remember|recall|save|transform|query|design|implement|compile|lint|refactor|summarize|check|use|call|invoke|file|spreadsheet|workbook|xlsx|calculator|python|code|script|memory|database|web|internet|slack|email|calendar|connector|plugin|mcp)\b/i;
|
||||
const EXPLICIT_CALCULATION_TOOL_PATTERN = /\b(?:file|spreadsheet|workbook|xlsx|calculator|python|code|script)\b/i;
|
||||
const IMPLICIT_CALCULATION_TOOL_NAMES = new Set(['calculator', 'run_code', 'generate_xlsx']);
|
||||
|
||||
const REPOSITORY_DISCOVERY_BUNDLE: IntentBundle = {
|
||||
pattern: /\b(repo(?:sitory)?|codebase)\b/i,
|
||||
tools: ['search_files', 'search_content', 'read_file', 'git_status', 'git_log'],
|
||||
};
|
||||
const REPOSITORY_DISCOVERY_TOOL_NAMES = new Set(REPOSITORY_DISCOVERY_BUNDLE.tools);
|
||||
|
||||
const INTENT_BUNDLES: readonly IntentBundle[] = [
|
||||
REPOSITORY_DISCOVERY_BUNDLE,
|
||||
{
|
||||
pattern: /\b(code|bug|fix|debug|test|build|compile|typecheck|lint|refactor|implement(?:ation)?|typescript|javascript|sql|etl|pipeline|diagnostic|verif(?:y|ication)|verdict)\b/i,
|
||||
tools: [
|
||||
'search_files', 'search_content', 'read_file', 'bash', 'run_code',
|
||||
'lsp_diagnostics', 'git_diff', 'git_status', 'edit_file', 'multi_edit',
|
||||
'write_file', 'lsp_definition', 'lsp_references', 'lsp_hover',
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: RESEARCH_INTENT_PATTERN,
|
||||
tools: [
|
||||
'search_memory', 'perplexity_search', 'tavily_search', 'brave_search',
|
||||
'web_search', 'web_fetch', 'read_file', 'query_knowledge',
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /\b(spreadsheet|excel|xlsx|workbook|runway|budget|cash flow|financial model|sensitivity)\b/i,
|
||||
tools: ['generate_xlsx', 'read_file', 'search_memory', 'run_code'],
|
||||
},
|
||||
{
|
||||
pattern: /\b(memo|report|brief|proposal|article|docx|word|document|draft|write|export)\b/i,
|
||||
tools: ['search_memory', 'read_file', 'generate_docx', 'write_file', 'edit_file', 'generate_pdf'],
|
||||
},
|
||||
{
|
||||
pattern: /\b(plan|roadmap|steps?|dependencies|milestones?|project|timeline)\b/i,
|
||||
tools: ['create_plan', 'add_plan_step', 'show_plan', 'execute_step', 'compose_workflow'],
|
||||
},
|
||||
{
|
||||
pattern: /\b(schedule|calendar|remind|recurring|cron|appointment|meeting time)\b/i,
|
||||
tools: ['create_schedule', 'list_schedules', 'delete_schedule', 'trigger_schedule'],
|
||||
},
|
||||
{
|
||||
pattern: /\b(agent|delegate|parallel|coordinator|coordinate|workflow|orchestrate|specialist|worker|synthesi[sz]e)\b/i,
|
||||
tools: [
|
||||
'spawn_agent', 'list_agents', 'get_agent_result', 'compose_workflow',
|
||||
'orchestrate_workflow', 'list_harnesses', 'run_harness',
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /\b(git|commit|branch|stash|push|pull|merge|diff|pr|repository history)\b/i,
|
||||
tools: [
|
||||
'git_status', 'git_diff', 'git_log', 'git_branch', 'git_stash',
|
||||
'git_pull', 'git_commit', 'git_push', 'git_merge', 'git_pr',
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /\b(browser|page|website|navigate|screenshot|click|form|fill|dom)\b/i,
|
||||
tools: [
|
||||
'browser_navigate', 'browser_snapshot', 'browser_screenshot',
|
||||
'browser_click', 'browser_fill', 'browser_evaluate',
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /\b(remember|recall|previous|prior notes?|saved memory|what did we|what do you remember|decision history)\b/i,
|
||||
tools: ['search_memory', 'search_all_workspaces', 'query_knowledge', 'get_identity', 'get_awareness', 'save_memory'],
|
||||
},
|
||||
{
|
||||
pattern: /\b(connector|integration|slack|notion|github|gitlab|postgres|email|gmail|outlook|calendar)\b/i,
|
||||
tools: ['find_connector', 'list_connector_categories'],
|
||||
},
|
||||
];
|
||||
|
||||
const TOKEN_STOPWORDS = new Set([
|
||||
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'can', 'do', 'for', 'from',
|
||||
'give', 'how', 'i', 'in', 'is', 'it', 'me', 'my', 'of', 'on', 'or', 'our',
|
||||
'please', 'that', 'the', 'their', 'this', 'to', 'use', 'what', 'when', 'where',
|
||||
'which', 'with', 'you', 'your', 'tool', 'tools', 'plugin', 'mcp', 'function',
|
||||
'input', 'object', 'properties', 'property', 'required', 'string', 'task',
|
||||
]);
|
||||
|
||||
function tokensOf(value: string): Set<string> {
|
||||
const tokens = value.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
||||
return new Set(tokens.filter(token => token.length > 1 && !TOKEN_STOPWORDS.has(token)));
|
||||
}
|
||||
|
||||
function overlapCount(left: ReadonlySet<string>, right: ReadonlySet<string>): number {
|
||||
let count = 0;
|
||||
for (const token of left) {
|
||||
if (right.has(token)) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function positiveIntentText(value: string): string {
|
||||
const startsWithNegatedClause = LEADING_NEGATED_TOOL_CLAUSE_PATTERN.test(value);
|
||||
let positiveText = value
|
||||
.replace(NEGATED_TOOL_CLAUSE_PATTERN, ' ')
|
||||
.replace(POST_VERBAL_NEGATED_TOOL_CLAUSE_PATTERN, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
if (startsWithNegatedClause) {
|
||||
positiveText = positiveText.replace(/^\s*[.;!?]\s*/, '');
|
||||
}
|
||||
return positiveText
|
||||
.replace(/^\s*(?:(?:i|we|you|they|he|she|it)\s*)?[,;:]?\s*(?:and|but|however|instead|then)\s+/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function findPreviousUserIntent(
|
||||
messages: readonly { role: string; content: string }[],
|
||||
requireFailedAttempt: boolean,
|
||||
): string {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const entry = messages[index];
|
||||
if (entry.role !== 'user') continue;
|
||||
const content = positiveIntentText(entry.content.toLowerCase());
|
||||
if (!DIRECT_ACTION_DIRECTIVE_PATTERN.test(content)
|
||||
&& !DIRECT_REPOSITORY_DISCOVERY_PATTERN.test(content)) {
|
||||
continue;
|
||||
}
|
||||
if (requireFailedAttempt) {
|
||||
const isDirectRepositoryDiscovery = DIRECT_REPOSITORY_DISCOVERY_PATTERN.test(content);
|
||||
const isReadOnlyRepositoryDiscovery = isDirectRepositoryDiscovery
|
||||
&& !REPOSITORY_EXECUTION_OR_MUTATION_PATTERN.test(content);
|
||||
const isDirectAction = DIRECT_ACTION_DIRECTIVE_PATTERN.test(content);
|
||||
if (!isDirectRepositoryDiscovery && !isDirectAction) continue;
|
||||
if (!isReadOnlyRepositoryDiscovery
|
||||
&& messages.slice(index + 1).some(candidate => candidate.role === 'user')) {
|
||||
continue;
|
||||
}
|
||||
const response = messages.slice(index + 1).find(candidate => candidate.role === 'assistant');
|
||||
if (!response || !TOOL_RETRY_CONTEXT_PATTERN.test(response.content)) continue;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isNegatedExecutionTool(tool: ToolDefinition, negatedClauses: readonly string[]): boolean {
|
||||
const name = tool.name.toLowerCase();
|
||||
return negatedClauses.some(clause => {
|
||||
const codeExecution = /\b(?:code|python|script)\b/i.test(clause)
|
||||
&& /^(?:run_code|bash|cli_execute)$/.test(name);
|
||||
const calculator = /\bcalculator\b/i.test(clause) && /calculator/.test(name);
|
||||
return codeExecution || calculator;
|
||||
});
|
||||
}
|
||||
|
||||
function hasInlineCalculationOperands(value: string): boolean {
|
||||
if (!DIRECT_CALCULATION_PATTERN.test(value)) return false;
|
||||
const operands = Array.from(
|
||||
value.matchAll(/(?:^|[^\p{L}\p{N}])([-+]?\d[\d,.]*)/gu),
|
||||
match => match[1].replace(/[,.]+$/, '').replace(/,/g, ''),
|
||||
);
|
||||
if (operands.length < 2) return false;
|
||||
if (CALCULATION_RELATION_PATTERN.test(value)) return true;
|
||||
return operands.some(operand => !/^(?:18|19|20|21)\d{2}$/.test(operand));
|
||||
}
|
||||
|
||||
function isSelfContainedCalculation(value: string): boolean {
|
||||
if (!hasInlineCalculationOperands(value)) return false;
|
||||
if (RESEARCH_INTENT_PATTERN.test(value)) return false;
|
||||
if (EXPLICIT_CALCULATION_CAPABILITY_PATTERN.test(value)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function toOpenAiTool(tool: ToolDefinition): {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
} {
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
...tool.parameters,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface ToolSelectionMetadata {
|
||||
name: string;
|
||||
description: string;
|
||||
parametersJson: string;
|
||||
normalizedName: string;
|
||||
nameTokens: ReadonlySet<string>;
|
||||
metadataTokens: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const TOOL_SELECTION_METADATA = new WeakMap<ToolDefinition, ToolSelectionMetadata>();
|
||||
|
||||
function selectionMetadata(tool: ToolDefinition): ToolSelectionMetadata {
|
||||
const cached = TOOL_SELECTION_METADATA.get(tool);
|
||||
const parametersJson = JSON.stringify(tool.parameters);
|
||||
if (cached
|
||||
&& cached.name === tool.name
|
||||
&& cached.description === tool.description
|
||||
&& cached.parametersJson === parametersJson) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const normalizedName = tool.name.toLowerCase();
|
||||
const metadata: ToolSelectionMetadata = {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parametersJson,
|
||||
normalizedName,
|
||||
nameTokens: tokensOf(normalizedName),
|
||||
metadataTokens: tokensOf(`${tool.description} ${parametersJson}`),
|
||||
};
|
||||
TOOL_SELECTION_METADATA.set(tool, metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/** Exact serialized character count for the schema array sent by agent-loop. */
|
||||
export function measureOpenAiToolSchemaChars(tools: readonly ToolDefinition[]): number {
|
||||
return JSON.stringify(tools.map(toOpenAiTool)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically select the smallest useful subset of an already-authorized
|
||||
* tool pool. This function can only subtract: it never creates or restores a
|
||||
* tool removed by persona, availability, schedule, or governance policy.
|
||||
*/
|
||||
export function selectToolsForTurn(
|
||||
eligibleTools: readonly ToolDefinition[],
|
||||
options: TurnToolSelectionOptions,
|
||||
): TurnToolSelectionResult {
|
||||
const maxTools = Number.isFinite(options.maxTools)
|
||||
? Math.max(0, Math.floor(options.maxTools ?? DEFAULT_TURN_TOOL_LIMIT))
|
||||
: DEFAULT_TURN_TOOL_LIMIT;
|
||||
const maxSchemaChars = Number.isFinite(options.maxSchemaChars)
|
||||
? Math.max(2, Math.floor(options.maxSchemaChars ?? DEFAULT_TURN_SCHEMA_CHAR_LIMIT))
|
||||
: DEFAULT_TURN_SCHEMA_CHAR_LIMIT;
|
||||
|
||||
const deduplicated: Array<{ tool: ToolDefinition; index: number }> = [];
|
||||
const seenNames = new Set<string>();
|
||||
for (let index = 0; index < eligibleTools.length; index += 1) {
|
||||
const candidate = eligibleTools[index];
|
||||
if (seenNames.has(candidate.name)) continue;
|
||||
seenNames.add(candidate.name);
|
||||
deduplicated.push({ tool: candidate, index });
|
||||
}
|
||||
|
||||
const rawMessage = options.message.toLowerCase();
|
||||
const negatedClauses = [...rawMessage.matchAll(NEGATED_TOOL_CLAUSE_PATTERN)].map(match => match[0]);
|
||||
const message = positiveIntentText(rawMessage);
|
||||
const messageTokens = tokensOf(message);
|
||||
const isRetryContinuation = RETRY_CONTINUATION_PATTERN.test(message);
|
||||
const isContinuation = CONTINUATION_PATTERN.test(message) || isRetryContinuation;
|
||||
const currentRepositoryDiscovery = DIRECT_REPOSITORY_DISCOVERY_PATTERN.test(message);
|
||||
const isAction = DIRECT_ACTION_DIRECTIVE_PATTERN.test(message)
|
||||
|| currentRepositoryDiscovery
|
||||
|| isContinuation;
|
||||
const recentMessages = options.recentMessages ?? [];
|
||||
const recentUserMessages = recentMessages
|
||||
.filter(entry => entry.role === 'user');
|
||||
const previousUserIntent = isContinuation
|
||||
? findPreviousUserIntent(recentMessages, isRetryContinuation)
|
||||
: '';
|
||||
const intentMessage = previousUserIntent
|
||||
? `${message} ${positiveIntentText(previousUserIntent.toLowerCase())}`
|
||||
: message;
|
||||
const inheritedRepositoryDiscovery = isContinuation
|
||||
&& previousUserIntent.length > 0
|
||||
&& REPOSITORY_DISCOVERY_PATTERN.test(previousUserIntent);
|
||||
const readOnlyRepositoryDiscovery = (currentRepositoryDiscovery || inheritedRepositoryDiscovery)
|
||||
&& !REPOSITORY_EXECUTION_OR_MUTATION_PATTERN.test(intentMessage);
|
||||
const matchedIntents = isAction
|
||||
? (readOnlyRepositoryDiscovery
|
||||
? [REPOSITORY_DISCOVERY_BUNDLE]
|
||||
: INTENT_BUNDLES.filter(bundle => bundle.pattern.test(intentMessage)))
|
||||
: [];
|
||||
const preferred = new Set(options.preferredToolNames ?? []);
|
||||
const mandatory = new Set(options.mandatoryToolNames ?? []);
|
||||
const external = new Set(options.externalToolNames ?? []);
|
||||
const retrieved = new Set(options.retrievedToolNames ?? []);
|
||||
const recent = new Set(Array.from(new Set(options.recentToolNames ?? [])).slice(-4));
|
||||
const suppressImplicitCalculationTools = hasInlineCalculationOperands(message)
|
||||
&& !EXPLICIT_CALCULATION_TOOL_PATTERN.test(message);
|
||||
|
||||
if (isContinuation
|
||||
&& previousUserIntent.length === 0
|
||||
&& !DIRECT_ACTION_DIRECTIVE_PATTERN.test(message)
|
||||
&& !currentRepositoryDiscovery) {
|
||||
return {
|
||||
tools: [],
|
||||
schemaChars: 2,
|
||||
omittedCount: deduplicated.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (mandatory.size === 0 && isSelfContainedCalculation(message)) {
|
||||
return {
|
||||
tools: [],
|
||||
schemaChars: 2,
|
||||
omittedCount: deduplicated.length,
|
||||
};
|
||||
}
|
||||
|
||||
const historyTokens = tokensOf(
|
||||
isContinuation
|
||||
? previousUserIntent
|
||||
: recentUserMessages.slice(-4).map(entry => entry.content).join(' '),
|
||||
);
|
||||
|
||||
const ranked: Array<{ tool: ToolDefinition; index: number; score: number }> = [];
|
||||
for (const { tool, index } of deduplicated) {
|
||||
if (readOnlyRepositoryDiscovery && !REPOSITORY_DISCOVERY_TOOL_NAMES.has(tool.name)) {
|
||||
continue;
|
||||
}
|
||||
if (!mandatory.has(tool.name) && isNegatedExecutionTool(tool, negatedClauses)) continue;
|
||||
if (suppressImplicitCalculationTools
|
||||
&& IMPLICIT_CALCULATION_TOOL_NAMES.has(tool.name)
|
||||
&& !mandatory.has(tool.name)) {
|
||||
continue;
|
||||
}
|
||||
const { normalizedName, nameTokens, metadataTokens } = selectionMetadata(tool);
|
||||
const exactName = isAction && message.includes(normalizedName);
|
||||
const currentNameOverlap = isAction ? overlapCount(messageTokens, nameTokens) : 0;
|
||||
const currentMetadataOverlap = isAction ? overlapCount(messageTokens, metadataTokens) : 0;
|
||||
const historyNameOverlap = isAction ? overlapCount(historyTokens, nameTokens) : 0;
|
||||
const historyMetadataOverlap = isAction ? overlapCount(historyTokens, metadataTokens) : 0;
|
||||
|
||||
let score = 0;
|
||||
let relevant = false;
|
||||
if (exactName) {
|
||||
score += 10_000;
|
||||
relevant = true;
|
||||
}
|
||||
if (mandatory.has(tool.name)) {
|
||||
score += 9_000;
|
||||
relevant = true;
|
||||
}
|
||||
if (isAction && retrieved.has(tool.name)) {
|
||||
score += 500;
|
||||
relevant = true;
|
||||
}
|
||||
for (const bundle of matchedIntents) {
|
||||
const bundleIndex = bundle.tools.indexOf(tool.name);
|
||||
if (bundleIndex >= 0) {
|
||||
score += 1_000 - bundleIndex;
|
||||
relevant = true;
|
||||
}
|
||||
}
|
||||
if (currentNameOverlap > 0) {
|
||||
score += currentNameOverlap * 200;
|
||||
relevant = true;
|
||||
}
|
||||
if (currentMetadataOverlap > 0) {
|
||||
score += currentMetadataOverlap * 20;
|
||||
relevant = true;
|
||||
}
|
||||
if (isAction && recent.has(tool.name)) {
|
||||
score += 150;
|
||||
relevant = true;
|
||||
}
|
||||
if (historyNameOverlap > 0) {
|
||||
score += historyNameOverlap * 50;
|
||||
relevant = true;
|
||||
}
|
||||
if (historyMetadataOverlap > 0) {
|
||||
score += historyMetadataOverlap * 5;
|
||||
relevant = true;
|
||||
}
|
||||
|
||||
if (external.has(tool.name)) {
|
||||
const explicitExternal = exactName
|
||||
|| currentNameOverlap > 0
|
||||
|| mandatory.has(tool.name)
|
||||
|| (isAction && retrieved.has(tool.name))
|
||||
|| (isContinuation && previousUserIntent.length > 0 && recent.has(tool.name));
|
||||
if (!explicitExternal) relevant = false;
|
||||
}
|
||||
|
||||
if (!relevant) continue;
|
||||
if (preferred.has(tool.name)) score += 1;
|
||||
ranked.push({ tool, index, score });
|
||||
}
|
||||
|
||||
if (ranked.length === 0
|
||||
&& options.fallbackToEligible
|
||||
&& !(negatedClauses.length > 0 && !isAction)) {
|
||||
for (const { tool, index } of deduplicated) {
|
||||
if (external.has(tool.name)) continue;
|
||||
if (readOnlyRepositoryDiscovery && !REPOSITORY_DISCOVERY_TOOL_NAMES.has(tool.name)) continue;
|
||||
if (suppressImplicitCalculationTools
|
||||
&& IMPLICIT_CALCULATION_TOOL_NAMES.has(tool.name)
|
||||
&& !mandatory.has(tool.name)) {
|
||||
continue;
|
||||
}
|
||||
ranked.push({ tool, index, score: preferred.has(tool.name) ? 1 : 0 });
|
||||
}
|
||||
}
|
||||
|
||||
ranked.sort((left, right) => right.score - left.score || left.index - right.index);
|
||||
|
||||
const selected: ToolDefinition[] = [];
|
||||
let schemaChars = 2;
|
||||
for (const candidate of ranked) {
|
||||
if (selected.length >= maxTools) break;
|
||||
const encodedLength = JSON.stringify(toOpenAiTool(candidate.tool)).length;
|
||||
const projected = selected.length === 0
|
||||
? 2 + encodedLength
|
||||
: schemaChars + 1 + encodedLength;
|
||||
if (projected > maxSchemaChars) continue;
|
||||
selected.push(candidate.tool);
|
||||
schemaChars = projected;
|
||||
}
|
||||
|
||||
return {
|
||||
tools: selected,
|
||||
schemaChars,
|
||||
omittedCount: eligibleTools.length - selected.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
* therefore route through HOOKS_COHORT, not the launchable registry.
|
||||
*/
|
||||
|
||||
import { spawn, execFile } from 'node:child_process';
|
||||
import { spawn, execFile, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { delimiter, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -52,6 +52,11 @@ import {
|
||||
resolveToolCommandInvocation,
|
||||
type ToolCommandInvocation,
|
||||
} from './tool-command.js';
|
||||
import { buildExternalProcessEnv } from './external-process-env.js';
|
||||
import {
|
||||
isSidecarOwnedProcessExitMessage,
|
||||
spawnSidecarOwnedProcess,
|
||||
} from './sidecar-owned-process.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -79,6 +84,8 @@ export interface ObservedHandle {
|
||||
export interface ToolLauncherDeps {
|
||||
/** Override platform (defaults to process.platform). */
|
||||
platform?: NodeJS.Platform;
|
||||
/** Test seam for the ambient process environment. */
|
||||
baseEnv?: NodeJS.ProcessEnv;
|
||||
/**
|
||||
* Detached-spawn implementation. Production uses `child_process.spawn`
|
||||
* with `detached: true`. Returns { pid } on success or { error }.
|
||||
@@ -124,7 +131,7 @@ function defaultSpawnDetached(
|
||||
const invocation = resolveSpawnInvocation(binary, args);
|
||||
const child = spawn(invocation.binary, invocation.args, {
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...(options.env ?? {}) },
|
||||
env: options.env,
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
|
||||
@@ -145,19 +152,20 @@ function defaultSpawnDetached(
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSpawnObserved(
|
||||
export function defaultSpawnObserved(
|
||||
binary: string,
|
||||
args: string[],
|
||||
options: { cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
): { pid: number | null; error?: string; handle?: ObservedHandle } {
|
||||
try {
|
||||
const invocation = resolveSpawnInvocation(binary, args);
|
||||
const child = spawn(invocation.binary, invocation.args, {
|
||||
const child = spawnSidecarOwnedProcess(invocation.binary, invocation.args, {
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...(options.env ?? {}) },
|
||||
// NOT detached, NOT unref'd: observation requires holding the pipes,
|
||||
// so the child is tethered to the sidecar lifecycle.
|
||||
env: options.env,
|
||||
// Observation keeps the pipes; IPC supervision makes that ownership
|
||||
// survive an abrupt Windows sidecar termination.
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
|
||||
});
|
||||
child.once('error', () => {
|
||||
@@ -166,15 +174,7 @@ function defaultSpawnObserved(
|
||||
if (child.pid == null) {
|
||||
return { pid: null, error: 'spawn returned no pid' };
|
||||
}
|
||||
const handle: ObservedHandle = {
|
||||
onData(cb) {
|
||||
child.stdout?.on('data', (d: Buffer) => cb(d.toString('utf8')));
|
||||
child.stderr?.on('data', (d: Buffer) => cb(d.toString('utf8')));
|
||||
},
|
||||
onExit(cb) {
|
||||
child.on('exit', (code) => cb(code));
|
||||
},
|
||||
};
|
||||
const handle = createObservedHandle(child);
|
||||
return { pid: child.pid, handle };
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -184,6 +184,22 @@ function defaultSpawnObserved(
|
||||
}
|
||||
}
|
||||
|
||||
export function createObservedHandle(child: ChildProcess): ObservedHandle {
|
||||
let targetExitCode: number | null | undefined;
|
||||
child.on('message', (message) => {
|
||||
if (isSidecarOwnedProcessExitMessage(message)) targetExitCode = message.code;
|
||||
});
|
||||
return {
|
||||
onData(cb) {
|
||||
child.stdout?.on('data', (d: Buffer) => cb(d.toString('utf8')));
|
||||
child.stderr?.on('data', (d: Buffer) => cb(d.toString('utf8')));
|
||||
},
|
||||
onExit(cb) {
|
||||
child.on('exit', (code) => cb(targetExitCode === undefined ? code : targetExitCode));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSpawnInvocation(
|
||||
binary: string,
|
||||
args: string[],
|
||||
@@ -201,7 +217,7 @@ async function defaultExecCapture(
|
||||
const invocation = resolveToolCommandInvocation(binary, args);
|
||||
const { stdout, stderr } = await execFileAsync(invocation.binary, invocation.args, {
|
||||
timeout: options?.timeoutMs ?? 30000,
|
||||
env: { ...process.env, ...(options?.env ?? {}) },
|
||||
env: options?.env,
|
||||
shell: false,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
@@ -224,6 +240,7 @@ async function defaultExecCapture(
|
||||
|
||||
interface ResolvedDeps {
|
||||
platform: NodeJS.Platform;
|
||||
baseEnv: NodeJS.ProcessEnv;
|
||||
spawnDetached: NonNullable<ToolLauncherDeps['spawnDetached']>;
|
||||
spawnObserved: NonNullable<ToolLauncherDeps['spawnObserved']>;
|
||||
execCapture: NonNullable<ToolLauncherDeps['execCapture']>;
|
||||
@@ -232,6 +249,7 @@ interface ResolvedDeps {
|
||||
function resolveDeps(opts: ToolLauncherDeps): ResolvedDeps {
|
||||
return {
|
||||
platform: opts.platform ?? process.platform,
|
||||
baseEnv: opts.baseEnv ?? process.env,
|
||||
spawnDetached: opts.spawnDetached ?? defaultSpawnDetached,
|
||||
spawnObserved: opts.spawnObserved ?? defaultSpawnObserved,
|
||||
execCapture: opts.execCapture ?? defaultExecCapture,
|
||||
@@ -350,28 +368,29 @@ export function launchTool(opts: LaunchOptions): LaunchResult {
|
||||
}
|
||||
const deps = resolveDeps(opts.deps ?? {});
|
||||
const args = opts.args ?? [];
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
const waggleEnv: NodeJS.ProcessEnv = {};
|
||||
if (opts.workspaceId) {
|
||||
env.WAGGLE_WORKSPACE_ID = opts.workspaceId;
|
||||
waggleEnv.WAGGLE_WORKSPACE_ID = opts.workspaceId;
|
||||
}
|
||||
// Self-enabling: light the SignalBus this pipeline was built to feed.
|
||||
// Opt out with signalEmit:false for a silent launch.
|
||||
if (opts.signalEmit !== false) {
|
||||
env.WAGGLE_SIGNAL_EMIT = '1';
|
||||
waggleEnv.WAGGLE_SIGNAL_EMIT = '1';
|
||||
}
|
||||
if (opts.sidecarUrl) {
|
||||
env.WAGGLE_SIDECAR_URL = opts.sidecarUrl;
|
||||
waggleEnv.WAGGLE_SIDECAR_URL = opts.sidecarUrl;
|
||||
}
|
||||
if (opts.dataDir) {
|
||||
env.HIVE_MIND_DATA_DIR = opts.dataDir;
|
||||
waggleEnv.HIVE_MIND_DATA_DIR = opts.dataDir;
|
||||
}
|
||||
if (opts.runId && opts.roomId && opts.runToken) {
|
||||
env.WAGGLE_RUN_ID = opts.runId;
|
||||
env.WAGGLE_ROOM_ID = opts.roomId;
|
||||
env.WAGGLE_DANCE_TEAM_ID = `room::${opts.roomId}`;
|
||||
env.WAGGLE_SENDER_ID = `run::${opts.runId}`;
|
||||
env.WAGGLE_RUN_TOKEN = opts.runToken;
|
||||
waggleEnv.WAGGLE_RUN_ID = opts.runId;
|
||||
waggleEnv.WAGGLE_ROOM_ID = opts.roomId;
|
||||
waggleEnv.WAGGLE_DANCE_TEAM_ID = `room::${opts.roomId}`;
|
||||
waggleEnv.WAGGLE_SENDER_ID = `run::${opts.runId}`;
|
||||
waggleEnv.WAGGLE_RUN_TOKEN = opts.runToken;
|
||||
}
|
||||
const env = buildExternalProcessEnv(deps.baseEnv, waggleEnv, deps.platform);
|
||||
// Observed mode: piped-stdio spawn that surfaces a live output handle.
|
||||
// Tethered to the sidecar (not unref'd) and tracked in-memory only.
|
||||
if (opts.observe) {
|
||||
@@ -539,13 +558,15 @@ export async function runHookCommand(
|
||||
};
|
||||
}
|
||||
const args = [runtime.hookEntry, opts.action];
|
||||
if (opts.action === 'install') args.push('--cli-path', runtime.cliEntry);
|
||||
if (opts.action === 'install' || (opts.action === 'verify' && opts.id === 'openclaw')) {
|
||||
args.push('--cli-path', runtime.cliEntry);
|
||||
}
|
||||
const result = await deps.execCapture(runtime.nodePath, args, {
|
||||
timeoutMs: 60000,
|
||||
env: {
|
||||
env: buildExternalProcessEnv(deps.baseEnv, {
|
||||
WAGGLE_HOOK_NODE_PATH: runtime.nodePath,
|
||||
...(opts.dataDir ? { HIVE_MIND_DATA_DIR: opts.dataDir } : {}),
|
||||
},
|
||||
}, deps.platform),
|
||||
});
|
||||
if (!result) {
|
||||
return {
|
||||
|
||||
@@ -33,8 +33,12 @@
|
||||
* - Per-process start-time fingerprinting to defeat pid reuse.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveWindowsTaskkillPath } from './external-tool-runner.js';
|
||||
|
||||
const WINDOWS_TREE_KILL_TIMEOUT_MS = 5_000;
|
||||
|
||||
export interface TrackedProcess {
|
||||
pid: number;
|
||||
@@ -66,6 +70,13 @@ export interface ToolProcessTrackerDeps {
|
||||
* escalation. Production = setTimeout-backed Promise.
|
||||
*/
|
||||
delay?: (ms: number) => Promise<void>;
|
||||
/** Platform override for deterministic Windows/POSIX termination tests. */
|
||||
platform?: NodeJS.Platform;
|
||||
/**
|
||||
* Windows process-tree terminator. Production uses bounded, shell-free
|
||||
* taskkill.exe /T /F and reports false on spawn, timeout, or exit failure.
|
||||
*/
|
||||
killTree?: (pid: number) => Promise<boolean>;
|
||||
/**
|
||||
* Path to the JSON pidfile used for cross-restart persistence. When
|
||||
* set (and no explicit load/save override is given), the tracker
|
||||
@@ -147,12 +158,25 @@ function defaultDelay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function defaultKillTree(pid: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
resolveWindowsTaskkillPath(),
|
||||
['/PID', String(pid), '/T', '/F'],
|
||||
{ timeout: WINDOWS_TREE_KILL_TIMEOUT_MS, windowsHide: true },
|
||||
(error) => resolve(error === null),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export class ToolProcessTracker {
|
||||
private processes: Map<number, TrackedProcess> = new Map();
|
||||
private readonly isAlive: (pid: number) => boolean;
|
||||
private readonly now: () => Date;
|
||||
private readonly sendSignal: (pid: number, signal: NodeJS.Signals | number) => boolean;
|
||||
private readonly delay: (ms: number) => Promise<void>;
|
||||
private readonly platform: NodeJS.Platform;
|
||||
private readonly killTree: (pid: number) => Promise<boolean>;
|
||||
/** Whether persistence is configured (persistPath or injected load/save). */
|
||||
private readonly persists: boolean;
|
||||
private readonly loadPersisted: () => TrackedProcess[];
|
||||
@@ -163,6 +187,8 @@ export class ToolProcessTracker {
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
this.sendSignal = deps.sendSignal ?? defaultSendSignal;
|
||||
this.delay = deps.delay ?? defaultDelay;
|
||||
this.platform = deps.platform ?? process.platform;
|
||||
this.killTree = deps.killTree ?? defaultKillTree;
|
||||
|
||||
this.persists = Boolean(deps.persistPath || deps.loadPersisted || deps.savePersisted);
|
||||
const persistPath = deps.persistPath;
|
||||
@@ -249,10 +275,10 @@ export class ToolProcessTracker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to stop a tracked process gracefully (SIGTERM), escalating
|
||||
* to SIGKILL after `gracefulTimeoutMs` if it's still alive. Returns
|
||||
* a structured result documenting which signal succeeded so the
|
||||
* route layer can surface honest UX.
|
||||
* Attempt to stop a tracked process. Windows uses bounded taskkill /T /F
|
||||
* so success covers the full descendant tree; POSIX uses SIGTERM and then
|
||||
* escalates to SIGKILL after `gracefulTimeoutMs`. Returns a structured
|
||||
* result so the route layer can surface honest UX.
|
||||
*
|
||||
* Refuses to kill a pid we don't track — this guards against the
|
||||
* UI accidentally sending an arbitrary OS pid (e.g. from URL
|
||||
@@ -267,6 +293,9 @@ export class ToolProcessTracker {
|
||||
reason:
|
||||
| 'not-tracked'
|
||||
| 'already-dead'
|
||||
| 'tree-cleanup-unverified'
|
||||
| 'tree-kill-ok'
|
||||
| 'tree-kill-failed'
|
||||
| 'sigterm-ok'
|
||||
| 'sigkill-ok'
|
||||
| 'sigterm-failed-sigkill-failed';
|
||||
@@ -275,11 +304,31 @@ export class ToolProcessTracker {
|
||||
return { ok: false, pid, reason: 'not-tracked' };
|
||||
}
|
||||
if (!this.isAlive(pid)) {
|
||||
if (this.platform === 'win32') {
|
||||
return { ok: false, pid, reason: 'tree-cleanup-unverified' };
|
||||
}
|
||||
// Already gone — GC the entry and report success.
|
||||
this.processes.delete(pid);
|
||||
this.persist();
|
||||
return { ok: true, pid, reason: 'already-dead' };
|
||||
}
|
||||
if (this.platform === 'win32') {
|
||||
let treeKilled = false;
|
||||
try {
|
||||
treeKilled = await this.killTree(pid);
|
||||
} catch {
|
||||
treeKilled = false;
|
||||
}
|
||||
if (treeKilled) {
|
||||
await this.delay(50);
|
||||
if (!this.isAlive(pid)) {
|
||||
this.processes.delete(pid);
|
||||
this.persist();
|
||||
return { ok: true, pid, reason: 'tree-kill-ok' };
|
||||
}
|
||||
}
|
||||
return { ok: false, pid, reason: 'tree-kill-failed' };
|
||||
}
|
||||
// Best effort: SIGTERM first so the child can clean up.
|
||||
const termSent = this.sendSignal(pid, 'SIGTERM');
|
||||
if (termSent) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import type { CognifyPipeline } from './cognify.js';
|
||||
import type { FeedbackHandler } from './feedback-handler.js';
|
||||
import type { ImprovementSignalStore } from '@waggle/core';
|
||||
import type { RiskLevel } from '@waggle/shared';
|
||||
import { createCoreLogger } from '@waggle/core';
|
||||
import { detectContradiction } from './contradiction-detector.js';
|
||||
import { scanForInjection } from './injection-scanner.js';
|
||||
@@ -27,6 +28,11 @@ export interface ToolDefinition {
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
execute: (args: Record<string, unknown>) => Promise<string>;
|
||||
/**
|
||||
* Provider-authored runtime risk. This is trusted metadata, never model input;
|
||||
* confirmation policy may use it only to elevate name-based risk.
|
||||
*/
|
||||
riskLevel?: RiskLevel;
|
||||
/** PM-6: Whether this tool can operate without LLM connectivity (default: false) */
|
||||
offlineCapable?: boolean;
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface TraceHandle {
|
||||
export interface FinalizeOptions {
|
||||
outcome: TraceOutcome;
|
||||
output: string;
|
||||
model?: string | null;
|
||||
tokens?: { input: number; output: number };
|
||||
costUsd?: number;
|
||||
harness?: TracePayload['harness'];
|
||||
@@ -214,6 +215,7 @@ export class TraceRecorder {
|
||||
const result = this.store.finalize(handle.id, {
|
||||
outcome: options.outcome,
|
||||
output: options.output,
|
||||
model: options.model,
|
||||
tokens: options.tokens,
|
||||
costUsd: options.costUsd,
|
||||
harness: options.harness,
|
||||
|
||||
@@ -18,14 +18,15 @@
|
||||
* them as literals, so they fall through to `dns.lookup`, whose getaddrinfo
|
||||
* backend returns the canonical dotted form we then classify.
|
||||
*
|
||||
* Dependency-free (node builtins only). A structurally identical guard lives at
|
||||
* `packages/hive-mind-core/src/harvest/url-egress-guard.ts` for the OSS-mirrored
|
||||
* harvest adapter (which must not import from @waggle/agent). Keep the two in
|
||||
* sync — they share this spec.
|
||||
* Socket pinning uses an Undici dispatcher whose connector consumes the same
|
||||
* address records this guard validates. The OSS-mirrored harvest adapter keeps
|
||||
* a separate implementation because it cannot import from @waggle/agent. Keep
|
||||
* the two in sync — they share this spec.
|
||||
*/
|
||||
|
||||
import { lookup as dnsLookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { isIP, type LookupFunction } from 'node:net';
|
||||
import { Agent } from 'undici';
|
||||
|
||||
export type AddressClass =
|
||||
| 'public'
|
||||
@@ -103,6 +104,7 @@ function classifyIpv4(ip: string): AddressClass {
|
||||
// Documentation / benchmark / protocol-assignment blocks — non-routable.
|
||||
if (a === 192 && b === 0 && c === 0) return 'reserved'; // 192.0.0.0/24
|
||||
if (a === 192 && b === 0 && c === 2) return 'reserved'; // TEST-NET-1
|
||||
if (a === 192 && b === 88 && c === 99) return 'reserved'; // Deprecated 6to4 relay anycast
|
||||
if (a === 198 && (b === 18 || b === 19)) return 'reserved'; // 198.18.0.0/15
|
||||
if (a === 198 && b === 51 && c === 100) return 'reserved'; // TEST-NET-2
|
||||
if (a === 203 && b === 0 && c === 113) return 'reserved'; // TEST-NET-3
|
||||
@@ -170,11 +172,20 @@ function classifyIpv6(ip: string): AddressClass {
|
||||
}
|
||||
|
||||
if ((h[0] & 0xffc0) === 0xfe80) return 'link-local'; // fe80::/10
|
||||
if ((h[0] & 0xffc0) === 0xfec0) return 'reserved'; // fec0::/10 deprecated site-local
|
||||
if ((h[0] & 0xfe00) === 0xfc00) return 'unique-local'; // fc00::/7 (ULA)
|
||||
if ((h[0] & 0xff00) === 0xff00) return 'multicast'; // ff00::/8
|
||||
if (h[0] === 0x2001 && h[1] === 0x0db8) return 'reserved'; // 2001:db8::/32 docs
|
||||
if (h[0] === 0x0064 && h[1] === 0xff9b) return 'reserved'; // 64:ff9b::/96 NAT64
|
||||
if (
|
||||
h[0] === 0x0064 && h[1] === 0xff9b
|
||||
&& ((h[2] === 0 && h[3] === 0 && h[4] === 0 && h[5] === 0) || h[2] === 1)
|
||||
) return 'reserved'; // 64:ff9b::/96 and 64:ff9b:1::/48 translation prefixes
|
||||
if (h[0] === 0x0100 && h[1] === 0 && h[2] === 0 && h[3] === 0) return 'reserved'; // 100::/64 discard
|
||||
if (h[0] === 0x0100 && h[1] === 0 && h[2] === 0 && h[3] === 1) return 'reserved'; // 100:0:0:1::/64 dummy
|
||||
if (h[0] === 0x2001 && h[1] === 2 && h[2] === 0) return 'reserved'; // 2001:2::/48 benchmark
|
||||
if (h[0] === 0x2002) return 'reserved'; // 2002::/16 deprecated 6to4
|
||||
if (h[0] === 0x3fff && (h[1] & 0xf000) === 0) return 'reserved'; // 3fff::/20 docs
|
||||
if (h[0] === 0x5f00) return 'reserved'; // 5f00::/16 SRv6 SIDs
|
||||
return 'public';
|
||||
}
|
||||
|
||||
@@ -201,6 +212,125 @@ async function defaultLookup(hostname: string): Promise<ResolvedAddress[]> {
|
||||
return results.map((r) => ({ address: r.address, family: r.family }));
|
||||
}
|
||||
|
||||
async function resolveHostname(
|
||||
hostname: string,
|
||||
rawUrl: string,
|
||||
lookupFn: LookupFn,
|
||||
): Promise<ResolvedAddress[]> {
|
||||
try {
|
||||
const addresses = await lookupFn(hostname);
|
||||
if (!addresses || addresses.length === 0) {
|
||||
throw new EgressBlockedError(
|
||||
`DNS resolution returned no addresses for "${hostname}"`,
|
||||
rawUrl,
|
||||
);
|
||||
}
|
||||
return addresses;
|
||||
} catch (err) {
|
||||
if (err instanceof EgressBlockedError) throw err;
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
throw new EgressBlockedError(
|
||||
`DNS resolution failed for "${hostname}": ${detail}`,
|
||||
rawUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateResolvedAddresses(
|
||||
addresses: ResolvedAddress[],
|
||||
hostname: string,
|
||||
rawUrl: string,
|
||||
allowLocal: boolean,
|
||||
): void {
|
||||
for (const { address } of addresses) {
|
||||
const cls = classifyAddress(address);
|
||||
if (!isAllowed(cls, allowLocal)) {
|
||||
throw new EgressBlockedError(
|
||||
`Blocked egress to ${cls} address ${address} (host "${hostname}")`,
|
||||
rawUrl,
|
||||
cls,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve, validate, and return the exact same addresses to the socket layer.
|
||||
* This removes the DNS validation/connect race: net/tls never performs a third
|
||||
* lookup after the records have passed the egress policy.
|
||||
*/
|
||||
function createGuardedLookup(
|
||||
allowLocal: boolean,
|
||||
lookupFn: LookupFn,
|
||||
): LookupFunction {
|
||||
return (hostname, options, callback) => {
|
||||
void resolveHostname(hostname, hostname, lookupFn)
|
||||
.then((addresses) => {
|
||||
validateResolvedAddresses(addresses, hostname, hostname, allowLocal);
|
||||
|
||||
const requestedFamily = options.family === 4 || options.family === 'IPv4'
|
||||
? 4
|
||||
: options.family === 6 || options.family === 'IPv6'
|
||||
? 6
|
||||
: 0;
|
||||
const candidates = requestedFamily === 0
|
||||
? addresses
|
||||
: addresses.filter(({ family }) => family === requestedFamily);
|
||||
if (candidates.length === 0) {
|
||||
throw new EgressBlockedError(
|
||||
`DNS resolution returned no IPv${requestedFamily} addresses for "${hostname}"`,
|
||||
hostname,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.all) {
|
||||
callback(null, candidates);
|
||||
} else {
|
||||
const selected = candidates[0];
|
||||
callback(null, selected.address, selected.family);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
callback(err as NodeJS.ErrnoException, '');
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createGuardedAgent(allowLocal: boolean, lookupFn: LookupFn): Agent {
|
||||
return new Agent({
|
||||
autoSelectFamily: true,
|
||||
connect: { lookup: createGuardedLookup(allowLocal, lookupFn) },
|
||||
});
|
||||
}
|
||||
|
||||
const defaultGuardedAgents = new Map<boolean, Agent>();
|
||||
|
||||
function getDefaultGuardedAgent(allowLocal: boolean): Agent {
|
||||
const existing = defaultGuardedAgents.get(allowLocal);
|
||||
if (existing) return existing;
|
||||
const agent = createGuardedAgent(allowLocal, defaultLookup);
|
||||
defaultGuardedAgents.set(allowLocal, agent);
|
||||
return agent;
|
||||
}
|
||||
|
||||
function findEgressBlockedError(
|
||||
error: unknown,
|
||||
seen = new Set<unknown>(),
|
||||
): EgressBlockedError | null {
|
||||
if (error instanceof EgressBlockedError) return error;
|
||||
if (typeof error !== 'object' || error === null || seen.has(error)) return null;
|
||||
seen.add(error);
|
||||
|
||||
if (error instanceof AggregateError) {
|
||||
for (const nested of error.errors) {
|
||||
const blocked = findEgressBlockedError(nested, seen);
|
||||
if (blocked) return blocked;
|
||||
}
|
||||
}
|
||||
|
||||
return findEgressBlockedError((error as { cause?: unknown }).cause, seen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that `rawUrl` is an http(s) URL whose host resolves only to
|
||||
* fetchable public addresses. Throws {@link EgressBlockedError} otherwise.
|
||||
@@ -224,6 +354,10 @@ export async function assertUrlAllowed(
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new EgressBlockedError('Blocked URL credentials', rawUrl);
|
||||
}
|
||||
|
||||
// url.hostname keeps the surrounding brackets on an IPv6 literal ("[::1]"),
|
||||
// which isIP() does not recognize — strip them so the literal is classified
|
||||
// directly (loopback/private/link-local/…) instead of falling through to a DNS
|
||||
@@ -238,34 +372,11 @@ export async function assertUrlAllowed(
|
||||
addresses = [{ address: hostname, family: literalFamily }];
|
||||
} else {
|
||||
const lookupFn = options.lookup ?? defaultLookup;
|
||||
try {
|
||||
addresses = await lookupFn(hostname);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
throw new EgressBlockedError(
|
||||
`DNS resolution failed for "${hostname}": ${detail}`,
|
||||
rawUrl,
|
||||
);
|
||||
}
|
||||
if (!addresses || addresses.length === 0) {
|
||||
throw new EgressBlockedError(
|
||||
`DNS resolution returned no addresses for "${hostname}"`,
|
||||
rawUrl,
|
||||
);
|
||||
}
|
||||
addresses = await resolveHostname(hostname, rawUrl, lookupFn);
|
||||
}
|
||||
|
||||
const allowLocal = options.allowLocal ?? false;
|
||||
for (const { address } of addresses) {
|
||||
const cls = classifyAddress(address);
|
||||
if (!isAllowed(cls, allowLocal)) {
|
||||
throw new EgressBlockedError(
|
||||
`Blocked egress to ${cls} address ${address} (host "${hostname}")`,
|
||||
rawUrl,
|
||||
cls,
|
||||
);
|
||||
}
|
||||
}
|
||||
validateResolvedAddresses(addresses, hostname, rawUrl, allowLocal);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
@@ -273,14 +384,78 @@ export async function assertUrlAllowed(
|
||||
export interface SafeFetchOptions extends EgressGuardOptions {
|
||||
/** Maximum redirect hops to follow (default 5). */
|
||||
maxRedirects?: number;
|
||||
/** Injectable fetch (tests). Defaults to globalThis.fetch. */
|
||||
fetchImpl?: typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
type FetchWithDispatcher = (
|
||||
input: string | URL | Request,
|
||||
init: RequestInit & { dispatcher: Agent },
|
||||
) => Promise<Response>;
|
||||
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
const CROSS_ORIGIN_SECRET_HEADERS = [
|
||||
'authorization',
|
||||
'proxy-authorization',
|
||||
'cookie',
|
||||
'cookie2',
|
||||
'x-api-key',
|
||||
'api-key',
|
||||
] as const;
|
||||
const REQUEST_BODY_HEADERS = [
|
||||
'content-encoding',
|
||||
'content-language',
|
||||
'content-length',
|
||||
'content-location',
|
||||
'content-type',
|
||||
] as const;
|
||||
|
||||
function isNonReplayableBody(body: BodyInit): boolean {
|
||||
const candidate = body as unknown as {
|
||||
getReader?: unknown;
|
||||
pipe?: unknown;
|
||||
[Symbol.asyncIterator]?: unknown;
|
||||
};
|
||||
return typeof candidate.getReader === 'function'
|
||||
|| typeof candidate.pipe === 'function'
|
||||
|| typeof candidate[Symbol.asyncIterator] === 'function';
|
||||
}
|
||||
|
||||
/** Apply Fetch's method/body policy and prevent credential forwarding. */
|
||||
function redirectRequestInit(
|
||||
init: RequestInit,
|
||||
status: number,
|
||||
fromUrl: URL,
|
||||
toUrl: URL,
|
||||
): RequestInit {
|
||||
const next = { ...init };
|
||||
const method = (next.method ?? 'GET').toUpperCase();
|
||||
const rewriteToGet = ((status === 301 || status === 302) && method === 'POST')
|
||||
|| (status === 303 && method !== 'GET' && method !== 'HEAD');
|
||||
const headersToDelete = new Set<string>(['host']);
|
||||
|
||||
if (rewriteToGet) {
|
||||
next.method = 'GET';
|
||||
delete next.body;
|
||||
for (const name of REQUEST_BODY_HEADERS) headersToDelete.add(name);
|
||||
} else if (next.body !== undefined && next.body !== null && isNonReplayableBody(next.body)) {
|
||||
throw new TypeError('Cannot replay a streamed request body across a redirect');
|
||||
}
|
||||
|
||||
if (fromUrl.origin !== toUrl.origin) {
|
||||
for (const name of CROSS_ORIGIN_SECRET_HEADERS) headersToDelete.add(name);
|
||||
}
|
||||
|
||||
const headers = new Headers(next.headers);
|
||||
for (const name of headersToDelete) headers.delete(name);
|
||||
next.headers = headers;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF-safe fetch. Validates the target before the request and re-validates
|
||||
* every redirect hop (`redirect: 'manual'`) so a public URL cannot redirect
|
||||
* into a private/link-local address. Caller-supplied `redirect` in `init` is
|
||||
* into a private/link-local address. Native fetch is mandatory; proxy transports
|
||||
* need an equivalent pinned connector rather than a global dispatcher override.
|
||||
* Caller-supplied `redirect` in `init` is
|
||||
* ignored — this helper owns redirect handling.
|
||||
*/
|
||||
export async function safeFetch(
|
||||
@@ -288,16 +463,46 @@ export async function safeFetch(
|
||||
init: RequestInit = {},
|
||||
options: SafeFetchOptions = {},
|
||||
): Promise<Response> {
|
||||
if ('fetchImpl' in options) {
|
||||
throw new TypeError('safeFetch fetchImpl injection is not supported; socket pinning requires native fetch');
|
||||
}
|
||||
const maxRedirects = options.maxRedirects ?? 5;
|
||||
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
||||
|
||||
let currentUrl = rawUrl;
|
||||
let currentInit = { ...init };
|
||||
for (let hop = 0; hop <= maxRedirects; hop++) {
|
||||
await assertUrlAllowed(currentUrl, options);
|
||||
const allowLocal = options.allowLocal ?? false;
|
||||
const temporaryAgent = options.lookup !== undefined;
|
||||
const dispatcher = temporaryAgent
|
||||
? createGuardedAgent(allowLocal, options.lookup!)
|
||||
: getDefaultGuardedAgent(allowLocal);
|
||||
|
||||
const response = await fetchImpl(currentUrl, { ...init, redirect: 'manual' });
|
||||
let response: Response;
|
||||
try {
|
||||
response = await (globalThis.fetch as unknown as FetchWithDispatcher)(currentUrl, {
|
||||
...currentInit,
|
||||
redirect: 'manual',
|
||||
dispatcher,
|
||||
});
|
||||
} catch (err) {
|
||||
if (temporaryAgent) {
|
||||
await dispatcher.close().catch(() => undefined);
|
||||
}
|
||||
const blocked = findEgressBlockedError(err);
|
||||
if (blocked) {
|
||||
throw new EgressBlockedError(blocked.message, currentUrl, blocked.addressClass);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const isRedirect = response.status >= 300 && response.status < 400;
|
||||
// A custom resolver gets an isolated Agent so tests and one-off policies
|
||||
// cannot contaminate pooled connections. close() is graceful: it waits for
|
||||
// the returned response body without blocking or aborting the caller.
|
||||
if (temporaryAgent) {
|
||||
void dispatcher.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
const isRedirect = REDIRECT_STATUSES.has(response.status);
|
||||
const location = isRedirect ? response.headers.get('location') : null;
|
||||
if (!location) {
|
||||
return response;
|
||||
@@ -310,16 +515,22 @@ export async function safeFetch(
|
||||
/* best-effort; ignore */
|
||||
}
|
||||
|
||||
let nextUrl: string;
|
||||
let nextUrl: URL;
|
||||
try {
|
||||
nextUrl = new URL(location, currentUrl).toString();
|
||||
nextUrl = new URL(location, currentUrl);
|
||||
} catch {
|
||||
throw new EgressBlockedError(
|
||||
`Invalid redirect target "${location}"`,
|
||||
currentUrl,
|
||||
);
|
||||
}
|
||||
currentUrl = nextUrl;
|
||||
currentInit = redirectRequestInit(
|
||||
currentInit,
|
||||
response.status,
|
||||
new URL(currentUrl),
|
||||
nextUrl,
|
||||
);
|
||||
currentUrl = nextUrl.toString();
|
||||
}
|
||||
|
||||
throw new EgressBlockedError(
|
||||
|
||||
@@ -21,12 +21,34 @@
|
||||
* so the bar to fire is deliberately high.
|
||||
*/
|
||||
|
||||
/** Tool-name fragments that count as actually running a check. */
|
||||
const VERIFICATION_TOOL = /test|build|\brun\b|run_|verif|lint|typecheck|tsc|pytest|jest|vitest|exec|bash|compile|spec/i;
|
||||
/** Exact general-purpose tools that can run or inspect a real check. */
|
||||
const VERIFICATION_TOOL_EXACT = new Set([
|
||||
'bash',
|
||||
'shell',
|
||||
'terminal',
|
||||
'powershell',
|
||||
'cmd',
|
||||
'run',
|
||||
'run_code',
|
||||
'run_harness',
|
||||
'exec',
|
||||
'exec_command',
|
||||
'execute_command',
|
||||
'cli_execute',
|
||||
'lsp_diagnostics',
|
||||
]);
|
||||
|
||||
/** Verification-specific whole name segments; avoids `inspect_*` matching `spec`. */
|
||||
const VERIFICATION_TOOL_SEGMENT = /(?:^|[_:-])(?:tests?|build|verification?|verify|lint|typecheck|tsc|pytest|jest|vitest|compile|diagnostics?|spec)(?:$|[_:-])/i;
|
||||
|
||||
export function isVerificationToolName(name: string): boolean {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return VERIFICATION_TOOL_EXACT.has(normalized) || VERIFICATION_TOOL_SEGMENT.test(normalized);
|
||||
}
|
||||
|
||||
/** Explicit "the work is verified / passing / working" success assertions. */
|
||||
const SUCCESS_ASSERTION: RegExp[] = [
|
||||
/\b(?:all\s+)?(?:tests?|suite|specs?)\s+(?:pass(?:ed|ing)?|are\s+green|is\s+green)\b/i,
|
||||
/\b(?:all\s+)?(?:tests?|suite|specs?)\s+(?:(?:are|is)\s+)?(?:pass(?:ed|ing)?|green)\b/i,
|
||||
/\b\d+\s+tests?\s+(?:pass(?:ed|ing)?|green)\b/i,
|
||||
/\bbuild\s+(?:succeed(?:s|ed)?|passes|is\s+green)\b/i,
|
||||
/\bit\s+(?:now\s+)?compiles?\b|\beverything\s+compiles\b/i,
|
||||
@@ -36,16 +58,170 @@ const SUCCESS_ASSERTION: RegExp[] = [
|
||||
/\b(?:confirmed|validated)\s+(?:it|the|that)\b[^.]*\b(?:works?|passes?|correct)\b/i,
|
||||
];
|
||||
|
||||
/** Requests whose output is expected to preserve claims supplied by the user. */
|
||||
const SOURCE_TRANSFORM_REQUEST = /\b(?:rewrite|rephrase|summari[sz]e|translate|preserve|quote|extract|polish|edit this|turn this into)\b/i;
|
||||
|
||||
/** Context that makes a success phrase a future condition rather than a completion claim. */
|
||||
const PLANNING_CONTEXT = /(?:\b(?:exit|acceptance|release|completion|success|quality)\s+(?:criteria|criterion|gate)\b|\bdefinition of done\b|\b(?:if|when|once|until|unless)\b|\b(?:must|should|needs? to|required|requires?|target|goal|planned|plan to|will)\b)/i;
|
||||
const PLANNING_HEADER = /(?:criteria|criterion|gate|definition of done|requirements?|target|goal|next checks?)/i;
|
||||
const TABLE_PLANNING_HEADER = /(?:pass conditions?|next checks?|exit criteria|acceptance criteria|requirements?)/i;
|
||||
const ATTRIBUTED_CONTEXT = /\b(?:you (?:said|reported|stated|provided)|according to (?:you|your message)|the supplied (?:text|claim)|reported|claimed)\b/i;
|
||||
|
||||
function normalizeAssertion(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function assertionContext(content: string, index: number): { line: string; previousLine: string } {
|
||||
const lineStart = content.lastIndexOf('\n', Math.max(0, index - 1)) + 1;
|
||||
const lineEndMatch = content.indexOf('\n', index);
|
||||
const lineEnd = lineEndMatch === -1 ? content.length : lineEndMatch;
|
||||
const previousEnd = Math.max(0, lineStart - 1);
|
||||
const previousStart = content.lastIndexOf('\n', Math.max(0, previousEnd - 1)) + 1;
|
||||
return {
|
||||
line: content.slice(lineStart, lineEnd),
|
||||
previousLine: content.slice(previousStart, previousEnd),
|
||||
};
|
||||
}
|
||||
|
||||
interface MarkdownTableCell {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function isEscapedDelimiter(line: string, index: number): boolean {
|
||||
let backslashes = 0;
|
||||
for (let cursor = index - 1; cursor >= 0 && line[cursor] === '\\'; cursor--) backslashes++;
|
||||
return backslashes % 2 === 1;
|
||||
}
|
||||
|
||||
function markdownTableCells(line: string): MarkdownTableCell[] | null {
|
||||
const delimiters: number[] = [];
|
||||
for (let index = 0; index < line.length; index++) {
|
||||
if (line[index] === '|' && !isEscapedDelimiter(line, index)) delimiters.push(index);
|
||||
}
|
||||
if (delimiters.length === 0) return null;
|
||||
|
||||
const boundaries = [-1, ...delimiters, line.length];
|
||||
const cells: MarkdownTableCell[] = [];
|
||||
for (let index = 0; index < boundaries.length - 1; index++) {
|
||||
const start = boundaries[index] + 1;
|
||||
const end = boundaries[index + 1];
|
||||
cells.push({
|
||||
text: line.slice(start, end).trim().replace(/\\\|/g, '|'),
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
if (cells[0]?.text === '') cells.shift();
|
||||
if (cells.at(-1)?.text === '') cells.pop();
|
||||
return cells.length >= 2 ? cells : null;
|
||||
}
|
||||
|
||||
function tableAssertionContext(
|
||||
content: string,
|
||||
line: string,
|
||||
lineStart: number,
|
||||
assertionIndex: number,
|
||||
): { cell: string; header: string } | null {
|
||||
const cells = markdownTableCells(line);
|
||||
if (!cells) return null;
|
||||
const relativeIndex = Math.max(0, assertionIndex - lineStart);
|
||||
const columnIndex = cells.findIndex(cell => relativeIndex >= cell.start && relativeIndex < cell.end);
|
||||
if (columnIndex < 0) return null;
|
||||
|
||||
const priorLines = content.slice(0, lineStart).split('\n');
|
||||
let cursor = priorLines.length - 1;
|
||||
if (priorLines[cursor] === '') cursor--;
|
||||
let header = '';
|
||||
for (; cursor >= 1; cursor--) {
|
||||
const rowCells = markdownTableCells(priorLines[cursor]);
|
||||
if (!rowCells) break;
|
||||
if (rowCells.every(cell => /^:?-{3,}:?$/.test(cell.text))) {
|
||||
const headerCells = markdownTableCells(priorLines[cursor - 1]);
|
||||
if (!headerCells || headerCells.length !== cells.length || rowCells.length !== cells.length) return null;
|
||||
header = headerCells[columnIndex]?.text ?? '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { cell: cells[columnIndex]?.text ?? '', header };
|
||||
}
|
||||
|
||||
function isPlanningListSection(content: string, lineStart: number): boolean {
|
||||
const headingStack: Array<{ level: number; text: string }> = [];
|
||||
let colonLabel = '';
|
||||
for (const line of content.slice(0, lineStart).split('\n')) {
|
||||
const heading = line.match(/^\s*(#{1,6})\s+(.+?)\s*$/);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
while (headingStack.at(-1)?.level !== undefined && headingStack.at(-1)!.level >= level) {
|
||||
headingStack.pop();
|
||||
}
|
||||
headingStack.push({ level, text: heading[2] });
|
||||
colonLabel = '';
|
||||
continue;
|
||||
}
|
||||
if (!/^\s*(?:[-*]|\d+[.)])\s+/.test(line) && /^\s*[^|#\r\n]{1,120}:\s*$/.test(line)) {
|
||||
colonLabel = line;
|
||||
}
|
||||
}
|
||||
return headingStack.some(heading => PLANNING_HEADER.test(heading.text))
|
||||
|| PLANNING_HEADER.test(colonLabel);
|
||||
}
|
||||
|
||||
function isPlanningCondition(content: string, index: number): boolean {
|
||||
const { line } = assertionContext(content, index);
|
||||
const isListItem = /^\s*(?:[-*]|\d+[.)])\s+/.test(line);
|
||||
const lineStart = content.lastIndexOf('\n', Math.max(0, index - 1)) + 1;
|
||||
const tableContext = tableAssertionContext(content, line, lineStart, index);
|
||||
if (tableContext) {
|
||||
return PLANNING_CONTEXT.test(tableContext.cell) || TABLE_PLANNING_HEADER.test(tableContext.header);
|
||||
}
|
||||
if (PLANNING_CONTEXT.test(line)) return true;
|
||||
if (!isListItem) return false;
|
||||
return isPlanningListSection(content, lineStart);
|
||||
}
|
||||
|
||||
function isSuppliedClaim(
|
||||
assertion: RegExp,
|
||||
matchText: string,
|
||||
content: string,
|
||||
index: number,
|
||||
userRequest: string,
|
||||
): boolean {
|
||||
if (SOURCE_TRANSFORM_REQUEST.test(userRequest)) {
|
||||
const flags = assertion.flags.replaceAll('g', '');
|
||||
if (new RegExp(assertion.source, flags).test(userRequest)) return true;
|
||||
}
|
||||
const normalizedMatch = normalizeAssertion(matchText);
|
||||
if (!normalizedMatch || !normalizeAssertion(userRequest).includes(normalizedMatch)) return false;
|
||||
return ATTRIBUTED_CONTEXT.test(assertionContext(content, index).line);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `content` asserts verified/passing/working completion but
|
||||
* none of `toolsUsed` is a verification-class tool — an unverified
|
||||
* completion claim that must not be accepted as "done".
|
||||
*/
|
||||
export function assertsUnverifiedCompletion(content: string, toolsUsed: readonly string[]): boolean {
|
||||
export function assertsUnverifiedCompletion(
|
||||
content: string,
|
||||
toolsUsed: readonly string[],
|
||||
userRequest = '',
|
||||
): boolean {
|
||||
if (!content || content.length < 12) return false;
|
||||
// A check actually ran this turn → the claim is grounded; do not fire.
|
||||
if (toolsUsed.some(t => VERIFICATION_TOOL.test(t))) return false;
|
||||
return SUCCESS_ASSERTION.some(re => re.test(content));
|
||||
if (toolsUsed.some(isVerificationToolName)) return false;
|
||||
for (const assertion of SUCCESS_ASSERTION) {
|
||||
const flags = assertion.flags.includes('g') ? assertion.flags : `${assertion.flags}g`;
|
||||
for (const match of content.matchAll(new RegExp(assertion.source, flags))) {
|
||||
const index = match.index ?? 0;
|
||||
if (isPlanningCondition(content, index)) continue;
|
||||
if (isSuppliedClaim(assertion, match[0], content, index, userRequest)) continue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,3 +234,7 @@ export const VERIFICATION_GATE_DIRECTIVE =
|
||||
+ 'run the check now (the tests / build / the original reproduction) and quote '
|
||||
+ 'the real output, OR explicitly label the result UNVERIFIED and say what '
|
||||
+ 'remains unchecked. Do not reassert success without evidence.';
|
||||
|
||||
export const VERIFICATION_NO_TOOL_DISCLOSURE =
|
||||
'\n\n**Verification: UNVERIFIED** — No verification-capable tool was available in this turn. '
|
||||
+ 'Any success or readiness condition above is therefore a proposed criterion, not a measured result.';
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
*/
|
||||
|
||||
import type { TaskShape, TaskShapeType, ComponentPhase } from './task-shape.js';
|
||||
import type { WorkflowTemplate, WorkflowStep } from './subagent-orchestrator.js';
|
||||
import {
|
||||
MAX_WORKFLOW_STEPS,
|
||||
type WorkflowTemplate,
|
||||
type WorkflowStep,
|
||||
} from './subagent-orchestrator.js';
|
||||
import type { LoadedSkill } from './prompt-loader.js';
|
||||
import type { WorkflowHarness } from './workflow-harness.js';
|
||||
import { matchHarness } from './builtin-harnesses.js';
|
||||
@@ -345,6 +349,14 @@ export function validateTemplate(template: WorkflowTemplate): ValidationError[]
|
||||
errors.push({ field: 'steps', message: 'At least one step is required' });
|
||||
return errors;
|
||||
}
|
||||
const workerCount = template.steps.length + (template.aggregation === 'synthesize' ? 1 : 0);
|
||||
if (workerCount > MAX_WORKFLOW_STEPS) {
|
||||
errors.push({
|
||||
field: 'steps',
|
||||
message: `Workflow worker limit exceeded: ${workerCount} > ${MAX_WORKFLOW_STEPS}`,
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
if (!['concatenate', 'last', 'synthesize'].includes(template.aggregation)) {
|
||||
errors.push({ field: 'aggregation', message: `Invalid aggregation: ${template.aggregation}` });
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface WorkflowToolsConfig extends OrchestratorConfig {
|
||||
onWorkerStatus?: (event: { workerId: string; status: string; workerState: import('./subagent-orchestrator.js').WorkerState }) => void;
|
||||
/** Durable host lifecycle. Generic embedders may omit it. */
|
||||
runAdapter?: WorkflowRunAdapter;
|
||||
/** Resolve explicit worker overrides before durable runs or model calls. */
|
||||
resolveModel?: (model: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export function createWorkflowTools(config: WorkflowToolsConfig): ToolDefinition[] {
|
||||
@@ -172,6 +174,23 @@ export function createWorkflowTools(config: WorkflowToolsConfig): ToolDefinition
|
||||
return 'Provide either a template name or an inline_template.';
|
||||
}
|
||||
|
||||
const resolveModel = config.resolveModel;
|
||||
if (resolveModel) {
|
||||
try {
|
||||
template = {
|
||||
...template,
|
||||
steps: await Promise.all(template.steps.map(async (step) => (
|
||||
step.model !== undefined
|
||||
? { ...step, model: await resolveModel(step.model) }
|
||||
: step
|
||||
))),
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return `## Workflow Error: ${template.name}\nCould not resolve a worker model: ${message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fire workflow:start hook
|
||||
if (config.hooks) {
|
||||
const hookResult = await config.hooks.fire('workflow:start', {
|
||||
@@ -218,8 +237,8 @@ export function createWorkflowTools(config: WorkflowToolsConfig): ToolDefinition
|
||||
let aggregated: string;
|
||||
try {
|
||||
({ results, aggregated } = await orchestrator.runWorkflow(template));
|
||||
if (runHandle && config.runAdapter?.complete) {
|
||||
await config.runAdapter.complete(runHandle, { results, aggregated });
|
||||
if (runHandle?.signal?.aborted) {
|
||||
throw new Error('Workflow run was cancelled');
|
||||
}
|
||||
|
||||
// Fire workflow:end hook
|
||||
@@ -230,6 +249,12 @@ export function createWorkflowTools(config: WorkflowToolsConfig): ToolDefinition
|
||||
workflowTask: task,
|
||||
});
|
||||
}
|
||||
if (runHandle?.signal?.aborted) {
|
||||
throw new Error('Workflow run was cancelled');
|
||||
}
|
||||
if (runHandle && config.runAdapter?.complete) {
|
||||
await config.runAdapter.complete(runHandle, { results, aggregated });
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
if (runHandle && config.runAdapter?.fail) {
|
||||
|
||||
Reference in New Issue
Block a user