wave-agent-sdk 0.19.9 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/builtin/plugins/sdd/scripts/session-start.js +1 -1
- package/builtin/plugins/sdd/skills/specify/SKILL.md +3 -4
- package/builtin/skills/settings/ENV.md +15 -9
- package/builtin/skills/settings/HOOKS.md +27 -2
- package/dist/agent.d.ts +9 -20
- package/dist/agent.js +28 -99
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/managers/aiManager.d.ts +71 -8
- package/dist/managers/aiManager.js +290 -85
- package/dist/managers/backgroundTaskManager.d.ts +6 -0
- package/dist/managers/backgroundTaskManager.js +11 -0
- package/dist/managers/bangManager.d.ts +6 -0
- package/dist/managers/bangManager.js +11 -0
- package/dist/managers/hookManager.d.ts +8 -2
- package/dist/managers/hookManager.js +14 -4
- package/dist/managers/mcpManager.d.ts +18 -4
- package/dist/managers/mcpManager.js +40 -18
- package/dist/managers/messageManager.d.ts +9 -5
- package/dist/managers/messageManager.js +36 -12
- package/dist/managers/subagentManager.d.ts +6 -0
- package/dist/managers/subagentManager.js +33 -22
- package/dist/managers/toolManager.js +5 -0
- package/dist/prompts/index.d.ts +0 -1
- package/dist/prompts/index.js +0 -4
- package/dist/services/aiService.d.ts +1 -34
- package/dist/services/aiService.js +18 -130
- package/dist/services/autoMemoryService.d.ts +27 -2
- package/dist/services/autoMemoryService.js +124 -36
- package/dist/services/configurationService.d.ts +21 -2
- package/dist/services/configurationService.js +86 -24
- package/dist/services/initializationService.js +14 -4
- package/dist/services/interactionService.js +35 -7
- package/dist/services/remoteSettingsService.d.ts +12 -0
- package/dist/services/remoteSettingsService.js +15 -1
- package/dist/services/session.d.ts +13 -0
- package/dist/services/session.js +64 -0
- package/dist/services/taskManager.js +7 -1
- package/dist/tools/bashTool.js +1 -0
- package/dist/tools/enterWorktreeTool.js +14 -3
- package/dist/tools/exitWorktreeTool.js +11 -10
- package/dist/tools/types.d.ts +7 -0
- package/dist/types/agent.d.ts +0 -2
- package/dist/types/config.d.ts +9 -0
- package/dist/types/core.d.ts +1 -1
- package/dist/types/hooks.d.ts +2 -2
- package/dist/utils/containerSetup.js +13 -4
- package/dist/utils/openaiClient.js +2 -1
- package/dist/utils/pathEncoder.js +7 -2
- package/dist/utils/worktreeUtils.d.ts +17 -0
- package/dist/utils/worktreeUtils.js +339 -1
- package/package.json +1 -1
- package/src/agent.ts +43 -112
- package/src/index.ts +1 -0
- package/src/managers/aiManager.ts +389 -110
- package/src/managers/backgroundTaskManager.ts +15 -0
- package/src/managers/bangManager.ts +15 -0
- package/src/managers/hookManager.ts +20 -5
- package/src/managers/mcpManager.ts +60 -18
- package/src/managers/messageManager.ts +51 -23
- package/src/managers/subagentManager.ts +36 -25
- package/src/managers/toolManager.ts +7 -0
- package/src/prompts/index.ts +0 -4
- package/src/services/aiService.ts +25 -203
- package/src/services/autoMemoryService.ts +145 -39
- package/src/services/configurationService.ts +100 -24
- package/src/services/initializationService.ts +17 -4
- package/src/services/interactionService.ts +49 -6
- package/src/services/remoteSettingsService.ts +16 -1
- package/src/services/session.ts +68 -0
- package/src/services/taskManager.ts +10 -1
- package/src/tools/bashTool.ts +1 -0
- package/src/tools/enterWorktreeTool.ts +19 -2
- package/src/tools/exitWorktreeTool.ts +15 -12
- package/src/tools/types.ts +7 -0
- package/src/types/agent.ts +0 -6
- package/src/types/config.ts +9 -0
- package/src/types/core.ts +1 -1
- package/src/types/hooks.ts +2 -2
- package/src/utils/containerSetup.ts +15 -5
- package/src/utils/openaiClient.ts +2 -0
- package/src/utils/pathEncoder.ts +7 -2
- package/src/utils/worktreeUtils.ts +401 -1
- package/dist/constants/goalPrompts.d.ts +0 -1
- package/dist/constants/goalPrompts.js +0 -10
- package/dist/managers/goalManager.d.ts +0 -42
- package/dist/managers/goalManager.js +0 -177
- package/src/constants/goalPrompts.ts +0 -10
- package/src/managers/goalManager.ts +0 -232
|
@@ -12,6 +12,10 @@ import { recoverTruncatedJson } from "../utils/stringUtils.js";
|
|
|
12
12
|
import { logger } from "../utils/globalLogger.js";
|
|
13
13
|
import { startInteractionSpan, endInteractionSpan, startLLMRequestSpan, endLLMRequestSpan, resetTracingState, } from "../telemetry/sessionTracing.js";
|
|
14
14
|
import { logOTelEvent } from "../telemetry/events.js";
|
|
15
|
+
/** Max turns for the compaction fork: the model should summarize, not act. */
|
|
16
|
+
const MAX_FORK_TURNS = 3;
|
|
17
|
+
/** Max turns for the auto-memory extraction fork. */
|
|
18
|
+
const MAX_AUTO_MEMORY_FORK_TURNS = 5;
|
|
15
19
|
// Truncate text to `max` chars and append a "… [+N chars]" marker when exceeded.
|
|
16
20
|
// Used for background_tasks description/command fields (≤1000 chars per spec FR-063).
|
|
17
21
|
function truncateWithMarker(text, max) {
|
|
@@ -99,9 +103,24 @@ export class AIManager {
|
|
|
99
103
|
get configurationService() {
|
|
100
104
|
return this.container.get("ConfigurationService");
|
|
101
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* OS env merged with the per-session env snapshot. Falls back to process.env
|
|
108
|
+
* when ConfigurationService is absent or its getMergedEnv is missing (e.g. in
|
|
109
|
+
* unit tests with partial mocks), so hook-context env construction never
|
|
110
|
+
* throws. Use this (not the non-null `configurationService` getter) when
|
|
111
|
+
* building hook context env.
|
|
112
|
+
*/
|
|
113
|
+
get mergedEnv() {
|
|
114
|
+
return (this.container
|
|
115
|
+
.get("ConfigurationService")
|
|
116
|
+
?.getMergedEnv?.() ?? process.env);
|
|
117
|
+
}
|
|
102
118
|
// Getter methods for accessing dynamic configuration
|
|
103
119
|
getGatewayConfig() {
|
|
104
|
-
return
|
|
120
|
+
return {
|
|
121
|
+
...this.configurationService.resolveGatewayConfig(),
|
|
122
|
+
sessionId: this.messageManager.getSessionId(),
|
|
123
|
+
};
|
|
105
124
|
}
|
|
106
125
|
getModelConfig() {
|
|
107
126
|
const permissionMode = this.container.has("PermissionMode")
|
|
@@ -438,7 +457,7 @@ export class AIManager {
|
|
|
438
457
|
}
|
|
439
458
|
catch (compactError) {
|
|
440
459
|
this.consecutiveCompactionFailures++;
|
|
441
|
-
logger?.error(`Failed to compact messages (${this.consecutiveCompactionFailures} consecutive)
|
|
460
|
+
logger?.error(`Failed to compact messages (${this.consecutiveCompactionFailures} consecutive): ${compactError instanceof Error ? compactError.message : String(compactError)}`);
|
|
442
461
|
this.messageManager.addErrorBlock(`Failed to compact conversation history: ${compactError instanceof Error ? compactError.message : String(compactError)}. You may encounter context limit issues.`);
|
|
443
462
|
}
|
|
444
463
|
finally {
|
|
@@ -475,16 +494,15 @@ export class AIManager {
|
|
|
475
494
|
return { toolsConfig, toolNames, filteredToolPlugins };
|
|
476
495
|
}
|
|
477
496
|
/**
|
|
478
|
-
* Fork-path
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
* another turn. Returns undefined content when the model never
|
|
484
|
-
* text; the caller treats that as a
|
|
497
|
+
* Fork-path loop: run a bounded agent loop over a copy of the conversation
|
|
498
|
+
* using the same system prompt, tools, model, and generation params as the
|
|
499
|
+
* main loop, so the forked request prefix matches exactly and the prompt
|
|
500
|
+
* cache is reused. A `canUseTool` gate decides whether each tool call
|
|
501
|
+
* executes locally (with a stripped context) or is denied and fed back to
|
|
502
|
+
* the model for another turn. Returns undefined content when the model never
|
|
503
|
+
* produces text; the caller treats that as a failure.
|
|
485
504
|
*/
|
|
486
|
-
async
|
|
487
|
-
const MAX_FORK_TURNS = 3;
|
|
505
|
+
async runForkLoop(historyMessages, prompt, options, abortSignal) {
|
|
488
506
|
const modelConfig = this.getModelConfig();
|
|
489
507
|
const gatewayConfig = this.getGatewayConfig();
|
|
490
508
|
const sessionId = this.messageManager.getSessionId();
|
|
@@ -498,12 +516,15 @@ export class AIManager {
|
|
|
498
516
|
content: wrapInSystemReminder(prependContent),
|
|
499
517
|
});
|
|
500
518
|
}
|
|
501
|
-
forkMessages.push({ role: "user", content:
|
|
519
|
+
forkMessages.push({ role: "user", content: prompt });
|
|
502
520
|
const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
|
|
503
521
|
const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
|
|
522
|
+
// Fresh read-state map so Read/Edit state built up inside the fork never
|
|
523
|
+
// leaks into the main session's dedup and staleness tracking.
|
|
524
|
+
const forkReadFileState = new Map();
|
|
504
525
|
let totalUsage;
|
|
505
526
|
let content;
|
|
506
|
-
for (let turn = 0; turn <
|
|
527
|
+
for (let turn = 0; turn < options.maxTurns; turn++) {
|
|
507
528
|
const result = await aiService.callAgent({
|
|
508
529
|
gatewayConfig,
|
|
509
530
|
modelConfig,
|
|
@@ -516,7 +537,7 @@ export class AIManager {
|
|
|
516
537
|
toolChoice: this.toolChoiceOverride,
|
|
517
538
|
// Stream so a slow reasoning model emits first bytes before the
|
|
518
539
|
// gateway's idle timeout fires (non-streaming waits for the full
|
|
519
|
-
//
|
|
540
|
+
// response, which exceeds the timeout on large contexts).
|
|
520
541
|
stream: true,
|
|
521
542
|
});
|
|
522
543
|
if (result.usage) {
|
|
@@ -532,28 +553,259 @@ export class AIManager {
|
|
|
532
553
|
break;
|
|
533
554
|
}
|
|
534
555
|
if (result.tool_calls && result.tool_calls.length > 0) {
|
|
535
|
-
|
|
536
|
-
|
|
556
|
+
const functionCalls = result.tool_calls.filter((tc) => tc.type === "function");
|
|
557
|
+
if (functionCalls.length === 0)
|
|
558
|
+
break;
|
|
537
559
|
forkMessages.push({
|
|
538
560
|
role: "assistant",
|
|
539
561
|
content: result.content ?? null,
|
|
540
|
-
tool_calls:
|
|
562
|
+
tool_calls: functionCalls,
|
|
541
563
|
});
|
|
542
|
-
for (const toolCall of
|
|
564
|
+
for (const toolCall of functionCalls) {
|
|
565
|
+
const name = toolCall.function?.name || "";
|
|
566
|
+
const args = this.parseForkToolArgs(toolCall.function?.arguments);
|
|
567
|
+
let toolContent;
|
|
568
|
+
if (options.canUseTool && options.canUseTool(name, args)) {
|
|
569
|
+
toolContent = await this.executeForkTool(name, args, workdir, sessionId, abortSignal, forkReadFileState);
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
toolContent =
|
|
573
|
+
options.deniedToolMessage ??
|
|
574
|
+
"Tool use is not allowed in this context";
|
|
575
|
+
}
|
|
543
576
|
forkMessages.push({
|
|
544
577
|
role: "tool",
|
|
545
578
|
tool_call_id: toolCall.id,
|
|
546
|
-
content:
|
|
579
|
+
content: toolContent,
|
|
547
580
|
});
|
|
548
581
|
}
|
|
549
582
|
continue;
|
|
550
583
|
}
|
|
551
584
|
// Neither text nor tool calls: retrying the identical request is
|
|
552
|
-
// pointless, bail out and let the caller fail
|
|
585
|
+
// pointless, bail out and let the caller fail.
|
|
553
586
|
break;
|
|
554
587
|
}
|
|
555
588
|
return { content, usage: totalUsage };
|
|
556
589
|
}
|
|
590
|
+
/**
|
|
591
|
+
* Fork-path compaction: deny all tool calls locally (the model is told to
|
|
592
|
+
* summarize, not act) and feed the rejections back for another turn.
|
|
593
|
+
*/
|
|
594
|
+
async runCompactFork(historyMessages, compactPrompt, abortSignal) {
|
|
595
|
+
return this.runForkLoop(historyMessages, compactPrompt, {
|
|
596
|
+
maxTurns: MAX_FORK_TURNS,
|
|
597
|
+
deniedToolMessage: "Tool use is not allowed during compaction",
|
|
598
|
+
}, abortSignal);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Auto-memory extraction via the perfect fork: the extraction prompt is run
|
|
602
|
+
* against the same request prefix as the main conversation (same system
|
|
603
|
+
* prompt, tools, model, and message history) so the prompt cache is reused.
|
|
604
|
+
* Gate-approved tools execute locally in a stripped context; everything else
|
|
605
|
+
* is denied. Usage is reported with operation_type "agent" so extraction
|
|
606
|
+
* token costs stay visible in session accounting.
|
|
607
|
+
*/
|
|
608
|
+
async runAutoMemoryFork(messages, prompt, options, abortSignal) {
|
|
609
|
+
const modelConfig = this.getModelConfig();
|
|
610
|
+
const historyMessages = convertMessagesForAPI(messages, {
|
|
611
|
+
supportsVision: supportsVision(modelConfig.capabilities),
|
|
612
|
+
});
|
|
613
|
+
// Give the fork a real signal even when the caller has none, so tools
|
|
614
|
+
// (e.g. Bash's foreground path) always receive a well-formed context.
|
|
615
|
+
const signal = abortSignal ?? new AbortController().signal;
|
|
616
|
+
const result = await this.runForkLoop(historyMessages, prompt, {
|
|
617
|
+
maxTurns: options.maxTurns ?? MAX_AUTO_MEMORY_FORK_TURNS,
|
|
618
|
+
canUseTool: options.canUseTool,
|
|
619
|
+
deniedToolMessage: options.deniedToolMessage,
|
|
620
|
+
}, signal);
|
|
621
|
+
if (result.usage && this.callbacks?.onUsageAdded) {
|
|
622
|
+
this.callbacks.onUsageAdded({
|
|
623
|
+
...result.usage,
|
|
624
|
+
model: modelConfig.model,
|
|
625
|
+
operation_type: "agent",
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
return result;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Parse a fork tool call's JSON arguments, recovering truncated JSON the
|
|
632
|
+
* same way the main loop does. Unparseable arguments fall back to `{}` and
|
|
633
|
+
* are rejected by the gate or the tool's own parameter validation.
|
|
634
|
+
*/
|
|
635
|
+
parseForkToolArgs(argsString) {
|
|
636
|
+
if (!argsString?.trim())
|
|
637
|
+
return {};
|
|
638
|
+
try {
|
|
639
|
+
return JSON.parse(argsString);
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
try {
|
|
643
|
+
return JSON.parse(recoverTruncatedJson(argsString));
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
return {};
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Execute a single tool call inside a fork with a stripped context: no
|
|
652
|
+
* permission manager (never prompts the user), no message manager (no
|
|
653
|
+
* conditional-rule triggering), no messageId (no file-history snapshots),
|
|
654
|
+
* and no background task manager (commands run in the foreground). Only
|
|
655
|
+
* gate-approved tool names reach this path.
|
|
656
|
+
*/
|
|
657
|
+
async executeForkTool(name, args, workdir, sessionId, abortSignal, readFileState) {
|
|
658
|
+
const plugin = this.toolManager.getTools().find((t) => t.name === name);
|
|
659
|
+
if (!plugin) {
|
|
660
|
+
return `Tool '${name}' not found`;
|
|
661
|
+
}
|
|
662
|
+
const context = {
|
|
663
|
+
abortSignal,
|
|
664
|
+
workdir,
|
|
665
|
+
originalWorkdir: this.originalWorkdir,
|
|
666
|
+
sessionId,
|
|
667
|
+
taskManager: this.taskManager,
|
|
668
|
+
readFileState,
|
|
669
|
+
onShortResultUpdate: () => { },
|
|
670
|
+
onResultUpdate: () => { },
|
|
671
|
+
onCwdChange: () => { },
|
|
672
|
+
};
|
|
673
|
+
try {
|
|
674
|
+
const result = await plugin.execute(args, context);
|
|
675
|
+
if (result.content)
|
|
676
|
+
return result.content;
|
|
677
|
+
if (result.error)
|
|
678
|
+
return `Error: ${result.error}`;
|
|
679
|
+
return "";
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
683
|
+
logger?.error(`Fork tool execution failed for ${name}:`, error);
|
|
684
|
+
return `Tool execution failed: ${message}`;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Fork-path side question ("/btw"): run a single-turn fork of the
|
|
689
|
+
* conversation using the same system prompt, tools, model, and generation
|
|
690
|
+
* params as the main loop, so the forked request prefix matches exactly and
|
|
691
|
+
* the prompt cache is reused. The in-progress assistant message (if any) is
|
|
692
|
+
* stripped so the fork starts from the last completed request prefix. Tools
|
|
693
|
+
* are never executed — the wrapped question instructs the model to answer
|
|
694
|
+
* directly; an attempted tool call is surfaced as an error string.
|
|
695
|
+
*/
|
|
696
|
+
async runBtwFork(question, abortSignal, onContent, onReasoning) {
|
|
697
|
+
const modelConfig = this.getModelConfig();
|
|
698
|
+
const gatewayConfig = this.getGatewayConfig();
|
|
699
|
+
const sessionId = this.messageManager.getSessionId();
|
|
700
|
+
const workdir = this.getWorkdir();
|
|
701
|
+
const rawMessages = this.messageManager.getMessages();
|
|
702
|
+
// Strip the in-progress assistant message (a block still in "streaming"
|
|
703
|
+
// stage) so the fork's request prefix matches the last completed
|
|
704
|
+
// main-loop request and the prompt cache is reused.
|
|
705
|
+
const lastMessage = rawMessages[rawMessages.length - 1];
|
|
706
|
+
const hasInProgressMessage = lastMessage?.role === "assistant" &&
|
|
707
|
+
lastMessage.blocks.some((b) => "stage" in b && b.stage === "streaming");
|
|
708
|
+
const forkMessages = convertMessagesForAPI(hasInProgressMessage ? rawMessages.slice(0, -1) : rawMessages, { supportsVision: supportsVision(modelConfig.capabilities) });
|
|
709
|
+
// Mirror the main loop's memory injection so the request prefix matches.
|
|
710
|
+
const { prependContent } = await this.messageManager.getMemoryForInjection();
|
|
711
|
+
if (prependContent.trim()) {
|
|
712
|
+
forkMessages.unshift({
|
|
713
|
+
role: "user",
|
|
714
|
+
content: wrapInSystemReminder(prependContent),
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
// Wrap the question with the side-question instructions (verbatim
|
|
718
|
+
// Claude Code sideQuestion.ts) so the model answers directly.
|
|
719
|
+
const wrappedQuestion = `<system-reminder>This is a side question from the user. You must answer this question directly in a single response.
|
|
720
|
+
|
|
721
|
+
IMPORTANT CONTEXT:
|
|
722
|
+
- You are a separate, lightweight agent spawned to answer this one question
|
|
723
|
+
- The main agent is NOT interrupted - it continues working independently in the background
|
|
724
|
+
- You share the conversation context but are a completely separate instance
|
|
725
|
+
- Do NOT reference being interrupted or what you were "previously doing" - that framing is incorrect
|
|
726
|
+
|
|
727
|
+
CRITICAL CONSTRAINTS:
|
|
728
|
+
- You have NO tools available - you cannot read files, run commands, search, or take any actions
|
|
729
|
+
- This is a one-off response - there will be no follow-up turns
|
|
730
|
+
- You can ONLY provide information based on what you already know from the conversation context
|
|
731
|
+
- NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action
|
|
732
|
+
- If you don't know the answer, say so - do not offer to look it up or investigate
|
|
733
|
+
|
|
734
|
+
Simply answer the question with the information you have.</system-reminder>
|
|
735
|
+
|
|
736
|
+
${question}`;
|
|
737
|
+
forkMessages.push({ role: "user", content: wrappedQuestion });
|
|
738
|
+
const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
|
|
739
|
+
const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
|
|
740
|
+
try {
|
|
741
|
+
// Surface partial output to the caller (e.g. the /btw overlay's
|
|
742
|
+
// streaming display) as it arrives. Reasoning chunks from thinking
|
|
743
|
+
// models stream through a separate channel when the caller supplies
|
|
744
|
+
// one (webview hosts distinguish thinking from content so the panel
|
|
745
|
+
// can drop thinking text once content starts); otherwise they fall
|
|
746
|
+
// back to the content channel (CLI overlay mixes both).
|
|
747
|
+
const streamToOverlay = (text) => {
|
|
748
|
+
if (text.trim()) {
|
|
749
|
+
onContent?.(text);
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
const streamReasoning = (text) => {
|
|
753
|
+
if (text.trim()) {
|
|
754
|
+
if (onReasoning) {
|
|
755
|
+
onReasoning(text);
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
onContent?.(text);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
const result = await aiService.callAgent({
|
|
763
|
+
gatewayConfig,
|
|
764
|
+
modelConfig,
|
|
765
|
+
messages: forkMessages,
|
|
766
|
+
sessionId,
|
|
767
|
+
abortSignal,
|
|
768
|
+
workdir,
|
|
769
|
+
tools: toolsConfig,
|
|
770
|
+
systemPrompt,
|
|
771
|
+
toolChoice: this.toolChoiceOverride,
|
|
772
|
+
// Stream so a slow reasoning model emits first bytes before the
|
|
773
|
+
// gateway's idle timeout fires (same rationale as runCompactFork).
|
|
774
|
+
stream: true,
|
|
775
|
+
onContentUpdate: streamToOverlay,
|
|
776
|
+
onReasoningUpdate: streamReasoning,
|
|
777
|
+
});
|
|
778
|
+
if (result.content?.trim()) {
|
|
779
|
+
return { content: result.content };
|
|
780
|
+
}
|
|
781
|
+
// A thinking model may emit only reasoning content (e.g. the stream
|
|
782
|
+
// is truncated before the final answer); surface that instead of
|
|
783
|
+
// falling through to "No response received".
|
|
784
|
+
if (result.reasoning_content?.trim()) {
|
|
785
|
+
return { content: result.reasoning_content };
|
|
786
|
+
}
|
|
787
|
+
if (result.tool_calls && result.tool_calls.length > 0) {
|
|
788
|
+
const firstFunctionCall = result.tool_calls.find((call) => call.type === "function");
|
|
789
|
+
const toolName = firstFunctionCall?.function?.name ?? "a tool";
|
|
790
|
+
return {
|
|
791
|
+
error: `(The model tried to call ${toolName} instead of answering directly. Try rephrasing or ask in the main conversation.)`,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
// Neither text nor tool calls.
|
|
795
|
+
return { error: "No response received" };
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
// aiService.callAgent converts AbortError into a plain Error, so the
|
|
799
|
+
// abort is detected via the signal itself; rethrow so the UI can
|
|
800
|
+
// silently dismiss instead of showing an error.
|
|
801
|
+
if (abortSignal?.aborted) {
|
|
802
|
+
throw error;
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
error: `(API error: ${error instanceof Error ? error.message : String(error)})`,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
}
|
|
557
809
|
/**
|
|
558
810
|
* Build post-compact context restoration content.
|
|
559
811
|
* Restores file reads, working directory, plan mode, skills, and background tasks.
|
|
@@ -790,6 +1042,11 @@ export class AIManager {
|
|
|
790
1042
|
systemPrompt: mainSystemPrompt, // Pass custom system prompt
|
|
791
1043
|
maxTokens: maxTokens, // Pass max tokens override
|
|
792
1044
|
toolChoice: this.toolChoiceOverride, // Pass tool_choice override
|
|
1045
|
+
// Fast-model subagents send disable-thinking params only when
|
|
1046
|
+
// explicitly configured (never in the agent loop).
|
|
1047
|
+
disableThinkingOptions: this.modelOverride === "fastModel"
|
|
1048
|
+
? this.getModelConfig().disableThinkingOptions
|
|
1049
|
+
: undefined,
|
|
793
1050
|
};
|
|
794
1051
|
// Prepend: AGENTS.md + user memory + unconditional rules as system-reminder
|
|
795
1052
|
if (prependContent.trim()) {
|
|
@@ -1103,67 +1360,15 @@ export class AIManager {
|
|
|
1103
1360
|
this.messageManager.addFileHistoryBlock(snapshots);
|
|
1104
1361
|
}
|
|
1105
1362
|
}
|
|
1106
|
-
//
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
//
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
if (circuitBreaker) {
|
|
1116
|
-
goalManager.clearGoal();
|
|
1117
|
-
logger?.info(`[Goal] ${circuitBreaker}`);
|
|
1118
|
-
this.messageManager.addUserMessage({
|
|
1119
|
-
content: `<system-reminder>${circuitBreaker}</system-reminder>`,
|
|
1120
|
-
isMeta: true,
|
|
1121
|
-
});
|
|
1122
|
-
// Fall through to normal Stop hooks on the final turn
|
|
1123
|
-
}
|
|
1124
|
-
else {
|
|
1125
|
-
// 2. Evaluate goal
|
|
1126
|
-
const evaluation = await goalManager.evaluateGoal(abortController.signal);
|
|
1127
|
-
if (evaluation.isMet) {
|
|
1128
|
-
goalManager.clearGoal();
|
|
1129
|
-
logger?.info(`[Goal] Goal achieved: ${evaluation.reason}`);
|
|
1130
|
-
this.messageManager.addUserMessage({
|
|
1131
|
-
content: `<system-reminder>Goal achieved: ${evaluation.reason}</system-reminder>`,
|
|
1132
|
-
isMeta: true,
|
|
1133
|
-
});
|
|
1134
|
-
// Fall through to normal Stop hooks on the final turn
|
|
1135
|
-
}
|
|
1136
|
-
else {
|
|
1137
|
-
const goal = goalManager.getGoal();
|
|
1138
|
-
goal.lastReason = evaluation.reason;
|
|
1139
|
-
logger?.info(`[Goal] Not yet met: ${evaluation.reason}`);
|
|
1140
|
-
this.messageManager.addUserMessage({
|
|
1141
|
-
content: `<system-reminder>Goal not yet met: ${evaluation.reason}. Continue working toward: ${goal.condition}</system-reminder>`,
|
|
1142
|
-
isMeta: true,
|
|
1143
|
-
});
|
|
1144
|
-
// Keep loading state active to prevent UI flicker
|
|
1145
|
-
this.setIsLoading(true);
|
|
1146
|
-
goalContinuing = true;
|
|
1147
|
-
// Restart outer loop to continue goal pursuit
|
|
1148
|
-
shouldRestart = true;
|
|
1149
|
-
turnOffset = 0;
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
// Skip Stop hooks when goal evaluator is continuing the conversation
|
|
1154
|
-
if (goalContinuing) {
|
|
1155
|
-
// Goal evaluator supersedes Stop hooks
|
|
1156
|
-
}
|
|
1157
|
-
else {
|
|
1158
|
-
const shouldContinue = await this.executeStopHooks();
|
|
1159
|
-
// If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
|
|
1160
|
-
// restart the AI conversation cycle
|
|
1161
|
-
if (shouldContinue) {
|
|
1162
|
-
logger?.info(`${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`);
|
|
1163
|
-
// Restart the conversation to let AI fix the issues
|
|
1164
|
-
shouldRestart = true;
|
|
1165
|
-
turnOffset = 0;
|
|
1166
|
-
}
|
|
1363
|
+
// Execute Stop hooks
|
|
1364
|
+
const shouldContinue = await this.executeStopHooks();
|
|
1365
|
+
// If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
|
|
1366
|
+
// restart the AI conversation cycle
|
|
1367
|
+
if (shouldContinue) {
|
|
1368
|
+
logger?.info(`${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`);
|
|
1369
|
+
// Restart the conversation to let AI fix the issues
|
|
1370
|
+
shouldRestart = true;
|
|
1371
|
+
turnOffset = 0;
|
|
1167
1372
|
}
|
|
1168
1373
|
}
|
|
1169
1374
|
// Inject pending notifications from background tasks (after Stop hooks,
|
|
@@ -1262,7 +1467,7 @@ export class AIManager {
|
|
|
1262
1467
|
sessionCrons, // Stop-only: session cron jobs snapshot
|
|
1263
1468
|
lastAssistantMessage: lastAssistantText, // Stop/SubagentStop: last assistant message text
|
|
1264
1469
|
// Stop hooks don't need toolName, toolInput, toolResponse, or userPrompt
|
|
1265
|
-
env: Object.fromEntries(Object.entries(
|
|
1470
|
+
env: Object.fromEntries(Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined)), // Include environment variables
|
|
1266
1471
|
};
|
|
1267
1472
|
const results = await this.hookManager.executeHooks(hookName, context);
|
|
1268
1473
|
// Process hook results to handle exit codes and appropriate responses
|
|
@@ -1417,7 +1622,7 @@ export class AIManager {
|
|
|
1417
1622
|
if (this.hookManager) {
|
|
1418
1623
|
const sessionId = this.messageManager.getSessionId();
|
|
1419
1624
|
const transcriptPath = this.messageManager.getTranscriptPath();
|
|
1420
|
-
const env = Object.fromEntries(Object.entries(
|
|
1625
|
+
const env = Object.fromEntries(Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined));
|
|
1421
1626
|
await this.hookManager.executeCwdChangedHooks(oldCwd, newCwd, sessionId, transcriptPath, env);
|
|
1422
1627
|
}
|
|
1423
1628
|
},
|
|
@@ -1484,7 +1689,7 @@ export class AIManager {
|
|
|
1484
1689
|
cwd: this.getWorkdir(),
|
|
1485
1690
|
toolInput,
|
|
1486
1691
|
subagentType: this.subagentType, // Include subagent type in hook context
|
|
1487
|
-
env: Object.fromEntries(Object.entries(
|
|
1692
|
+
env: Object.fromEntries(Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined)), // Include environment variables
|
|
1488
1693
|
};
|
|
1489
1694
|
const results = await this.hookManager.executeHooks("PreToolUse", context);
|
|
1490
1695
|
// Process hook results to handle exit codes and determine if tool should be blocked
|
|
@@ -1537,7 +1742,7 @@ export class AIManager {
|
|
|
1537
1742
|
toolResponse,
|
|
1538
1743
|
subagentType: this.subagentType, // Include subagent type in hook context
|
|
1539
1744
|
planFilePath: this.permissionManager?.getPlanFilePath(),
|
|
1540
|
-
env: Object.fromEntries(Object.entries(
|
|
1745
|
+
env: Object.fromEntries(Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined)), // Include environment variables
|
|
1541
1746
|
};
|
|
1542
1747
|
const results = await this.hookManager.executeHooks("PostToolUse", context);
|
|
1543
1748
|
// Process hook results to handle exit codes and update tool results
|
|
@@ -15,6 +15,12 @@ export declare class BackgroundTaskManager {
|
|
|
15
15
|
private callbacks;
|
|
16
16
|
private workdir;
|
|
17
17
|
constructor(container: Container, options: BackgroundTaskManagerOptions);
|
|
18
|
+
/**
|
|
19
|
+
* Merged env (OS env overlaid with this session's settings snapshot) for
|
|
20
|
+
* background-task subprocesses, so settings `env` vars reach them without
|
|
21
|
+
* polluting other sessions in one `wave --stdio` process.
|
|
22
|
+
*/
|
|
23
|
+
private get sessionEnv();
|
|
18
24
|
/**
|
|
19
25
|
* Fire the onBackgroundTasksChange callback so UI consumers refresh.
|
|
20
26
|
* Public so other managers (e.g. WorkflowManager) can trigger a refresh
|
|
@@ -13,6 +13,16 @@ export class BackgroundTaskManager {
|
|
|
13
13
|
this.callbacks = options.callbacks || {};
|
|
14
14
|
this.workdir = options.workdir;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Merged env (OS env overlaid with this session's settings snapshot) for
|
|
18
|
+
* background-task subprocesses, so settings `env` vars reach them without
|
|
19
|
+
* polluting other sessions in one `wave --stdio` process.
|
|
20
|
+
*/
|
|
21
|
+
get sessionEnv() {
|
|
22
|
+
return (this.container
|
|
23
|
+
.get("ConfigurationService")
|
|
24
|
+
?.getMergedEnv?.() ?? process.env);
|
|
25
|
+
}
|
|
16
26
|
/**
|
|
17
27
|
* Fire the onBackgroundTasksChange callback so UI consumers refresh.
|
|
18
28
|
* Public so other managers (e.g. WorkflowManager) can trigger a refresh
|
|
@@ -44,6 +54,7 @@ export class BackgroundTaskManager {
|
|
|
44
54
|
cwd: cwd ?? this.workdir,
|
|
45
55
|
env: {
|
|
46
56
|
...process.env,
|
|
57
|
+
...this.sessionEnv,
|
|
47
58
|
},
|
|
48
59
|
});
|
|
49
60
|
// Create log file
|
|
@@ -14,6 +14,12 @@ export declare class BangManager {
|
|
|
14
14
|
onCommandRunningChange?: (running: boolean) => void;
|
|
15
15
|
constructor(container: Container, options: BangManagerOptions);
|
|
16
16
|
private get messageManager();
|
|
17
|
+
/**
|
|
18
|
+
* Merged env (OS env overlaid with this session's settings snapshot) for
|
|
19
|
+
* bang-command subprocesses, so settings `env` vars reach them without
|
|
20
|
+
* polluting other sessions in one `wave --stdio` process.
|
|
21
|
+
*/
|
|
22
|
+
private get sessionEnv();
|
|
17
23
|
private setCommandRunning;
|
|
18
24
|
executeCommand(command: string): Promise<number>;
|
|
19
25
|
abortCommand(): void;
|
|
@@ -10,6 +10,16 @@ export class BangManager {
|
|
|
10
10
|
get messageManager() {
|
|
11
11
|
return this.container.get("MessageManager");
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Merged env (OS env overlaid with this session's settings snapshot) for
|
|
15
|
+
* bang-command subprocesses, so settings `env` vars reach them without
|
|
16
|
+
* polluting other sessions in one `wave --stdio` process.
|
|
17
|
+
*/
|
|
18
|
+
get sessionEnv() {
|
|
19
|
+
return (this.container
|
|
20
|
+
.get("ConfigurationService")
|
|
21
|
+
?.getMergedEnv?.() ?? process.env);
|
|
22
|
+
}
|
|
13
23
|
setCommandRunning(isRunning) {
|
|
14
24
|
this.isCommandRunning = isRunning;
|
|
15
25
|
this.onCommandRunningChange?.(isRunning);
|
|
@@ -28,6 +38,7 @@ export class BangManager {
|
|
|
28
38
|
cwd: this.workdir,
|
|
29
39
|
env: {
|
|
30
40
|
...process.env,
|
|
41
|
+
...this.sessionEnv,
|
|
31
42
|
},
|
|
32
43
|
});
|
|
33
44
|
this.currentProcess = child;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Central orchestrator for the hooks system. Handles configuration loading,
|
|
5
5
|
* validation, and hook execution across all supported events.
|
|
6
6
|
*/
|
|
7
|
-
import { type HookEvent, type HookExecutionContext, type ExtendedHookExecutionContext, type HookExecutionResult, type HookValidationResult, type SessionEndSource } from "../types/hooks.js";
|
|
7
|
+
import { type HookEvent, type HookExecutionContext, type ExtendedHookExecutionContext, type HookExecutionResult, type HookValidationResult, type SessionEndSource, type SessionStartSource } from "../types/hooks.js";
|
|
8
8
|
import type { WaveConfiguration, PartialHookConfiguration } from "../types/configuration.js";
|
|
9
9
|
import { HookMatcher } from "../utils/hookMatcher.js";
|
|
10
10
|
import type { MessageManager } from "./messageManager.js";
|
|
@@ -18,6 +18,12 @@ export declare class HookManager {
|
|
|
18
18
|
private readonly matcher;
|
|
19
19
|
private readonly workdir;
|
|
20
20
|
constructor(container: Container, workdir: string, matcher?: HookMatcher);
|
|
21
|
+
/**
|
|
22
|
+
* Merged env for this session (OS env overlaid with the per-session settings
|
|
23
|
+
* snapshot). Hook subprocesses spawn with this env so settings.json `env`
|
|
24
|
+
* vars reach hooks without polluting other sessions in one stdio process.
|
|
25
|
+
*/
|
|
26
|
+
private get sessionEnv();
|
|
21
27
|
/**
|
|
22
28
|
* Load hook configuration from programmatic source (AgentOptions.hooks)
|
|
23
29
|
*/
|
|
@@ -118,7 +124,7 @@ export declare class HookManager {
|
|
|
118
124
|
* Execute SessionStart hooks during initialization.
|
|
119
125
|
* Collects additionalContext and initialUserMessage from hook stdout.
|
|
120
126
|
*/
|
|
121
|
-
executeSessionStartHooks(source:
|
|
127
|
+
executeSessionStartHooks(source: SessionStartSource, sessionId: string, transcriptPath: string, agentType?: string): Promise<{
|
|
122
128
|
results: HookExecutionResult[];
|
|
123
129
|
additionalContext?: string;
|
|
124
130
|
initialUserMessage?: string;
|
|
@@ -18,6 +18,16 @@ export class HookManager {
|
|
|
18
18
|
this.workdir = workdir;
|
|
19
19
|
this.matcher = matcher;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Merged env for this session (OS env overlaid with the per-session settings
|
|
23
|
+
* snapshot). Hook subprocesses spawn with this env so settings.json `env`
|
|
24
|
+
* vars reach hooks without polluting other sessions in one stdio process.
|
|
25
|
+
*/
|
|
26
|
+
get sessionEnv() {
|
|
27
|
+
return (this.container
|
|
28
|
+
.get("ConfigurationService")
|
|
29
|
+
?.getMergedEnv?.() ?? process.env);
|
|
30
|
+
}
|
|
21
31
|
/**
|
|
22
32
|
* Load hook configuration from programmatic source (AgentOptions.hooks)
|
|
23
33
|
*/
|
|
@@ -674,7 +684,7 @@ export class HookManager {
|
|
|
674
684
|
cwd: this.workdir,
|
|
675
685
|
source,
|
|
676
686
|
agentType,
|
|
677
|
-
env: Object.fromEntries(Object.entries(
|
|
687
|
+
env: Object.fromEntries(Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined)),
|
|
678
688
|
};
|
|
679
689
|
const results = await this.executeHooks("SessionStart", context);
|
|
680
690
|
let additionalContext;
|
|
@@ -718,7 +728,7 @@ export class HookManager {
|
|
|
718
728
|
transcriptPath,
|
|
719
729
|
cwd: this.workdir,
|
|
720
730
|
endSource: source,
|
|
721
|
-
env: Object.fromEntries(Object.entries(
|
|
731
|
+
env: Object.fromEntries(Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined)),
|
|
722
732
|
};
|
|
723
733
|
const results = await this.executeHooks("SessionEnd", context);
|
|
724
734
|
// Process results but never block shutdown
|
|
@@ -740,7 +750,7 @@ export class HookManager {
|
|
|
740
750
|
transcriptPath,
|
|
741
751
|
cwd: this.workdir,
|
|
742
752
|
compactInstructions: customInstructions,
|
|
743
|
-
env: Object.fromEntries(Object.entries(
|
|
753
|
+
env: Object.fromEntries(Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined)),
|
|
744
754
|
};
|
|
745
755
|
const results = await this.executeHooks("PreCompact", context);
|
|
746
756
|
let additionalInstructions;
|
|
@@ -767,7 +777,7 @@ export class HookManager {
|
|
|
767
777
|
transcriptPath,
|
|
768
778
|
cwd: this.workdir,
|
|
769
779
|
compactSummary,
|
|
770
|
-
env: Object.fromEntries(Object.entries(
|
|
780
|
+
env: Object.fromEntries(Object.entries(this.sessionEnv).filter((e) => e[1] !== undefined)),
|
|
771
781
|
};
|
|
772
782
|
const results = await this.executeHooks("PostCompact", context);
|
|
773
783
|
if (results.length > 0) {
|