This commit is contained in:
Oleg Maslov
2026-09-02 10:14:22 +02:00
parent 0c3e2ead3b
commit b20b138fe4
771 changed files with 161561 additions and 9027 deletions

View File

@@ -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 },
};

View 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'),
};
});
}

View File

@@ -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).

View File

@@ -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 01 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(

View File

@@ -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() ?? '',
});

View File

@@ -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.

View File

@@ -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

View File

@@ -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,

View File

@@ -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> {

View File

@@ -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 },
);
}
}

View File

@@ -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 },
);
}
}

View File

@@ -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 */

View File

@@ -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') };

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 */ }
}
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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[] {

View File

@@ -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,

View File

@@ -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 };
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -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);

View File

@@ -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 users 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.',
],
},

View File

@@ -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> = {

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,
},
};
}

View 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('');
}

View 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,
});
}

View File

@@ -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. */

View File

@@ -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 {

View File

@@ -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' };
}

View File

@@ -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;

View File

@@ -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,
};
}

View File

@@ -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');
}
}
}

View File

@@ -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 = {

View File

@@ -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;
}

View File

@@ -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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/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';

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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,
);
}

View File

@@ -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 911 still receive the full sanitized result.
* observers at 911 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) ──

View File

@@ -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,
};
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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;
/**

View File

@@ -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,

View File

@@ -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(

View File

@@ -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.';

View File

@@ -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}` });
}

View File

@@ -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) {