wave-agent-sdk 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +9 -20
- package/dist/agent.js +35 -97
- package/dist/managers/aiManager.d.ts +63 -8
- package/dist/managers/aiManager.js +274 -80
- package/dist/managers/messageManager.d.ts +9 -5
- package/dist/managers/messageManager.js +36 -12
- package/dist/managers/permissionManager.js +13 -11
- package/dist/managers/subagentManager.d.ts +6 -0
- package/dist/managers/subagentManager.js +33 -22
- package/dist/prompts/index.d.ts +0 -2
- package/dist/prompts/index.js +71 -47
- 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.js +14 -1
- package/dist/services/session.d.ts +13 -0
- package/dist/services/session.js +64 -0
- package/dist/types/agent.d.ts +0 -2
- package/dist/types/config.d.ts +7 -0
- package/dist/types/core.d.ts +1 -1
- package/dist/utils/containerSetup.js +12 -3
- package/package.json +1 -1
- package/src/agent.ts +50 -110
- package/src/managers/aiManager.ts +370 -105
- package/src/managers/messageManager.ts +51 -23
- package/src/managers/permissionManager.ts +15 -13
- package/src/managers/subagentManager.ts +36 -25
- package/src/prompts/index.ts +75 -56
- package/src/services/aiService.ts +25 -203
- package/src/services/autoMemoryService.ts +145 -39
- package/src/services/configurationService.ts +16 -1
- package/src/services/session.ts +68 -0
- package/src/types/agent.ts +0 -6
- package/src/types/config.ts +7 -0
- package/src/types/core.ts +1 -1
- package/src/utils/containerSetup.ts +12 -4
- 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) {
|
|
@@ -453,7 +457,7 @@ export class AIManager {
|
|
|
453
457
|
}
|
|
454
458
|
catch (compactError) {
|
|
455
459
|
this.consecutiveCompactionFailures++;
|
|
456
|
-
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)}`);
|
|
457
461
|
this.messageManager.addErrorBlock(`Failed to compact conversation history: ${compactError instanceof Error ? compactError.message : String(compactError)}. You may encounter context limit issues.`);
|
|
458
462
|
}
|
|
459
463
|
finally {
|
|
@@ -490,16 +494,15 @@ export class AIManager {
|
|
|
490
494
|
return { toolsConfig, toolNames, filteredToolPlugins };
|
|
491
495
|
}
|
|
492
496
|
/**
|
|
493
|
-
* Fork-path
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
* another turn. Returns undefined content when the model never
|
|
499
|
-
* 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.
|
|
500
504
|
*/
|
|
501
|
-
async
|
|
502
|
-
const MAX_FORK_TURNS = 3;
|
|
505
|
+
async runForkLoop(historyMessages, prompt, options, abortSignal) {
|
|
503
506
|
const modelConfig = this.getModelConfig();
|
|
504
507
|
const gatewayConfig = this.getGatewayConfig();
|
|
505
508
|
const sessionId = this.messageManager.getSessionId();
|
|
@@ -513,12 +516,15 @@ export class AIManager {
|
|
|
513
516
|
content: wrapInSystemReminder(prependContent),
|
|
514
517
|
});
|
|
515
518
|
}
|
|
516
|
-
forkMessages.push({ role: "user", content:
|
|
519
|
+
forkMessages.push({ role: "user", content: prompt });
|
|
517
520
|
const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
|
|
518
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();
|
|
519
525
|
let totalUsage;
|
|
520
526
|
let content;
|
|
521
|
-
for (let turn = 0; turn <
|
|
527
|
+
for (let turn = 0; turn < options.maxTurns; turn++) {
|
|
522
528
|
const result = await aiService.callAgent({
|
|
523
529
|
gatewayConfig,
|
|
524
530
|
modelConfig,
|
|
@@ -531,7 +537,7 @@ export class AIManager {
|
|
|
531
537
|
toolChoice: this.toolChoiceOverride,
|
|
532
538
|
// Stream so a slow reasoning model emits first bytes before the
|
|
533
539
|
// gateway's idle timeout fires (non-streaming waits for the full
|
|
534
|
-
//
|
|
540
|
+
// response, which exceeds the timeout on large contexts).
|
|
535
541
|
stream: true,
|
|
536
542
|
});
|
|
537
543
|
if (result.usage) {
|
|
@@ -547,28 +553,259 @@ export class AIManager {
|
|
|
547
553
|
break;
|
|
548
554
|
}
|
|
549
555
|
if (result.tool_calls && result.tool_calls.length > 0) {
|
|
550
|
-
|
|
551
|
-
|
|
556
|
+
const functionCalls = result.tool_calls.filter((tc) => tc.type === "function");
|
|
557
|
+
if (functionCalls.length === 0)
|
|
558
|
+
break;
|
|
552
559
|
forkMessages.push({
|
|
553
560
|
role: "assistant",
|
|
554
561
|
content: result.content ?? null,
|
|
555
|
-
tool_calls:
|
|
562
|
+
tool_calls: functionCalls,
|
|
556
563
|
});
|
|
557
|
-
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
|
+
}
|
|
558
576
|
forkMessages.push({
|
|
559
577
|
role: "tool",
|
|
560
578
|
tool_call_id: toolCall.id,
|
|
561
|
-
content:
|
|
579
|
+
content: toolContent,
|
|
562
580
|
});
|
|
563
581
|
}
|
|
564
582
|
continue;
|
|
565
583
|
}
|
|
566
584
|
// Neither text nor tool calls: retrying the identical request is
|
|
567
|
-
// pointless, bail out and let the caller fail
|
|
585
|
+
// pointless, bail out and let the caller fail.
|
|
568
586
|
break;
|
|
569
587
|
}
|
|
570
588
|
return { content, usage: totalUsage };
|
|
571
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
|
+
}
|
|
572
809
|
/**
|
|
573
810
|
* Build post-compact context restoration content.
|
|
574
811
|
* Restores file reads, working directory, plan mode, skills, and background tasks.
|
|
@@ -805,6 +1042,11 @@ export class AIManager {
|
|
|
805
1042
|
systemPrompt: mainSystemPrompt, // Pass custom system prompt
|
|
806
1043
|
maxTokens: maxTokens, // Pass max tokens override
|
|
807
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,
|
|
808
1050
|
};
|
|
809
1051
|
// Prepend: AGENTS.md + user memory + unconditional rules as system-reminder
|
|
810
1052
|
if (prependContent.trim()) {
|
|
@@ -1118,67 +1360,15 @@ export class AIManager {
|
|
|
1118
1360
|
this.messageManager.addFileHistoryBlock(snapshots);
|
|
1119
1361
|
}
|
|
1120
1362
|
}
|
|
1121
|
-
//
|
|
1122
|
-
const
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
//
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
if (circuitBreaker) {
|
|
1131
|
-
goalManager.clearGoal();
|
|
1132
|
-
logger?.info(`[Goal] ${circuitBreaker}`);
|
|
1133
|
-
this.messageManager.addUserMessage({
|
|
1134
|
-
content: `<system-reminder>${circuitBreaker}</system-reminder>`,
|
|
1135
|
-
isMeta: true,
|
|
1136
|
-
});
|
|
1137
|
-
// Fall through to normal Stop hooks on the final turn
|
|
1138
|
-
}
|
|
1139
|
-
else {
|
|
1140
|
-
// 2. Evaluate goal
|
|
1141
|
-
const evaluation = await goalManager.evaluateGoal(abortController.signal);
|
|
1142
|
-
if (evaluation.isMet) {
|
|
1143
|
-
goalManager.clearGoal();
|
|
1144
|
-
logger?.info(`[Goal] Goal achieved: ${evaluation.reason}`);
|
|
1145
|
-
this.messageManager.addUserMessage({
|
|
1146
|
-
content: `<system-reminder>Goal achieved: ${evaluation.reason}</system-reminder>`,
|
|
1147
|
-
isMeta: true,
|
|
1148
|
-
});
|
|
1149
|
-
// Fall through to normal Stop hooks on the final turn
|
|
1150
|
-
}
|
|
1151
|
-
else {
|
|
1152
|
-
const goal = goalManager.getGoal();
|
|
1153
|
-
goal.lastReason = evaluation.reason;
|
|
1154
|
-
logger?.info(`[Goal] Not yet met: ${evaluation.reason}`);
|
|
1155
|
-
this.messageManager.addUserMessage({
|
|
1156
|
-
content: `<system-reminder>Goal not yet met: ${evaluation.reason}. Continue working toward: ${goal.condition}</system-reminder>`,
|
|
1157
|
-
isMeta: true,
|
|
1158
|
-
});
|
|
1159
|
-
// Keep loading state active to prevent UI flicker
|
|
1160
|
-
this.setIsLoading(true);
|
|
1161
|
-
goalContinuing = true;
|
|
1162
|
-
// Restart outer loop to continue goal pursuit
|
|
1163
|
-
shouldRestart = true;
|
|
1164
|
-
turnOffset = 0;
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
// Skip Stop hooks when goal evaluator is continuing the conversation
|
|
1169
|
-
if (goalContinuing) {
|
|
1170
|
-
// Goal evaluator supersedes Stop hooks
|
|
1171
|
-
}
|
|
1172
|
-
else {
|
|
1173
|
-
const shouldContinue = await this.executeStopHooks();
|
|
1174
|
-
// If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
|
|
1175
|
-
// restart the AI conversation cycle
|
|
1176
|
-
if (shouldContinue) {
|
|
1177
|
-
logger?.info(`${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`);
|
|
1178
|
-
// Restart the conversation to let AI fix the issues
|
|
1179
|
-
shouldRestart = true;
|
|
1180
|
-
turnOffset = 0;
|
|
1181
|
-
}
|
|
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;
|
|
1182
1372
|
}
|
|
1183
1373
|
}
|
|
1184
1374
|
// Inject pending notifications from background tasks (after Stop hooks,
|
|
@@ -1416,6 +1606,8 @@ export class AIManager {
|
|
|
1416
1606
|
id: toolId,
|
|
1417
1607
|
shortResult,
|
|
1418
1608
|
stage: "running",
|
|
1609
|
+
compactParams,
|
|
1610
|
+
name: toolName,
|
|
1419
1611
|
});
|
|
1420
1612
|
},
|
|
1421
1613
|
onResultUpdate: (result) => {
|
|
@@ -1423,6 +1615,8 @@ export class AIManager {
|
|
|
1423
1615
|
id: toolId,
|
|
1424
1616
|
result,
|
|
1425
1617
|
stage: "running",
|
|
1618
|
+
compactParams,
|
|
1619
|
+
name: toolName,
|
|
1426
1620
|
});
|
|
1427
1621
|
},
|
|
1428
1622
|
onCwdChange: async (newCwd) => {
|
|
@@ -5,7 +5,6 @@ import { ChatCompletionMessageFunctionToolCall } from "openai/resources.js";
|
|
|
5
5
|
import type { MemoryRule } from "../types/memoryRule.js";
|
|
6
6
|
import { Container } from "../utils/container.js";
|
|
7
7
|
export interface MessageManagerCallbacks {
|
|
8
|
-
onMessagesChange?: (messages: Message[]) => void;
|
|
9
8
|
onSessionIdChange?: (sessionId: string) => void;
|
|
10
9
|
onLatestTotalTokensChange?: (latestTotalTokens: number) => void;
|
|
11
10
|
onUsagesChange?: (usages: Usage[]) => void;
|
|
@@ -27,9 +26,9 @@ export interface MessageManagerCallbacks {
|
|
|
27
26
|
onErrorBlockAdded?: (error: string) => void;
|
|
28
27
|
onCompactBlockAdded?: (content: string) => void;
|
|
29
28
|
onCompactionStateChange?: (isCompacting: boolean) => void;
|
|
30
|
-
onAddBangMessage?: (command: string) => void;
|
|
31
|
-
onUpdateBangMessage?: (command: string, output: string) => void;
|
|
32
|
-
onCompleteBangMessage?: (command: string, exitCode: number) => void;
|
|
29
|
+
onAddBangMessage?: (command: string, messageId: string) => void;
|
|
30
|
+
onUpdateBangMessage?: (command: string, output: string, messageId: string) => void;
|
|
31
|
+
onCompleteBangMessage?: (command: string, exitCode: number, messageId: string) => void;
|
|
33
32
|
onInfoBlockAdded?: (content: string) => void;
|
|
34
33
|
onShowRewind?: () => void;
|
|
35
34
|
onFileHistoryBlockAdded?: (snapshots: import("../types/reversion.js").FileSnapshot[]) => void;
|
|
@@ -151,6 +150,12 @@ export declare class MessageManager {
|
|
|
151
150
|
addBangMessage(command: string): void;
|
|
152
151
|
updateBangMessage(command: string, output: string): void;
|
|
153
152
|
completeBangMessage(command: string, exitCode: number, output?: string): void;
|
|
153
|
+
/**
|
|
154
|
+
* Find the message ID of the most recent message containing a bang block
|
|
155
|
+
* for the given command. Bang callbacks do not carry a block ID, so the
|
|
156
|
+
* message is located by matching the command against bang blocks.
|
|
157
|
+
*/
|
|
158
|
+
private findBangMessageId;
|
|
154
159
|
addNotificationMessage(params: Omit<AddNotificationMessageParams, "messages">): void;
|
|
155
160
|
/**
|
|
156
161
|
* Rebuild usage array from messages containing usage metadata
|
|
@@ -169,7 +174,6 @@ export declare class MessageManager {
|
|
|
169
174
|
/**
|
|
170
175
|
* Finalize a streaming block of the given type by setting its stage to "end".
|
|
171
176
|
* Fires the corresponding incremental callback with chunk="" to signal finalization.
|
|
172
|
-
* Does NOT call onMessagesChange — the caller is responsible for that.
|
|
173
177
|
* Returns true if a block was finalized.
|
|
174
178
|
*/
|
|
175
179
|
private finalizeStreamingBlock;
|
|
@@ -181,7 +181,6 @@ export class MessageManager {
|
|
|
181
181
|
this.extractFileReadsFromMessage(messages[messages.length - 1]);
|
|
182
182
|
this.extractSkillInvocationsFromMessage(messages[messages.length - 1]);
|
|
183
183
|
}
|
|
184
|
-
this.callbacks.onMessagesChange?.([...messages]);
|
|
185
184
|
}
|
|
186
185
|
/**
|
|
187
186
|
* Save current session
|
|
@@ -194,6 +193,17 @@ export class MessageManager {
|
|
|
194
193
|
// No new messages to save
|
|
195
194
|
return;
|
|
196
195
|
}
|
|
196
|
+
// CC-aligned lazy materialization: when the session file has not been
|
|
197
|
+
// materialized yet (no saved messages) and every unsaved message is a
|
|
198
|
+
// meta message (isMeta: true, e.g. SessionStart hook context), skip
|
|
199
|
+
// persistence. Persisting meta-only sessions would create "0 tokens /
|
|
200
|
+
// No content" ghost entries in the resume list. The meta messages stay
|
|
201
|
+
// in memory and are flushed together with the first real user/assistant
|
|
202
|
+
// message (matches CC's pendingEntries buffering).
|
|
203
|
+
if (this.savedMessageCount === 0 &&
|
|
204
|
+
unsavedMessages.every((m) => m.isMeta)) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
197
207
|
// Create session if needed (only when we have messages to save)
|
|
198
208
|
if (this.savedMessageCount === 0) {
|
|
199
209
|
// This is the first time saving messages, so create the session
|
|
@@ -403,7 +413,9 @@ export class MessageManager {
|
|
|
403
413
|
command,
|
|
404
414
|
});
|
|
405
415
|
this.setMessages(updatedMessages);
|
|
406
|
-
|
|
416
|
+
// The bang message is appended as the last message
|
|
417
|
+
const messageId = this.messages[this.messages.length - 1]?.id ?? "";
|
|
418
|
+
this.callbacks.onAddBangMessage?.(command, messageId);
|
|
407
419
|
}
|
|
408
420
|
updateBangMessage(command, output) {
|
|
409
421
|
const updatedMessages = updateBangInMessage({
|
|
@@ -412,7 +424,8 @@ export class MessageManager {
|
|
|
412
424
|
output,
|
|
413
425
|
});
|
|
414
426
|
this.setMessages(updatedMessages);
|
|
415
|
-
this.
|
|
427
|
+
const messageId = this.findBangMessageId(command) ?? "";
|
|
428
|
+
this.callbacks.onUpdateBangMessage?.(command, output, messageId);
|
|
416
429
|
}
|
|
417
430
|
completeBangMessage(command, exitCode, output) {
|
|
418
431
|
const updatedMessages = completeBangInMessage({
|
|
@@ -422,7 +435,24 @@ export class MessageManager {
|
|
|
422
435
|
output,
|
|
423
436
|
});
|
|
424
437
|
this.setMessages(updatedMessages);
|
|
425
|
-
this.
|
|
438
|
+
const messageId = this.findBangMessageId(command) ?? "";
|
|
439
|
+
this.callbacks.onCompleteBangMessage?.(command, exitCode, messageId);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Find the message ID of the most recent message containing a bang block
|
|
443
|
+
* for the given command. Bang callbacks do not carry a block ID, so the
|
|
444
|
+
* message is located by matching the command against bang blocks.
|
|
445
|
+
*/
|
|
446
|
+
findBangMessageId(command) {
|
|
447
|
+
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
448
|
+
const message = this.messages[i];
|
|
449
|
+
if (message.role !== "user")
|
|
450
|
+
continue;
|
|
451
|
+
const hasBangBlock = message.blocks?.some((block) => block.type === "bang" && block.command === command);
|
|
452
|
+
if (hasBangBlock)
|
|
453
|
+
return message.id;
|
|
454
|
+
}
|
|
455
|
+
return undefined;
|
|
426
456
|
}
|
|
427
457
|
addNotificationMessage(params) {
|
|
428
458
|
const newMessages = addNotificationMessageToMessages({
|
|
@@ -468,7 +498,6 @@ export class MessageManager {
|
|
|
468
498
|
/**
|
|
469
499
|
* Finalize a streaming block of the given type by setting its stage to "end".
|
|
470
500
|
* Fires the corresponding incremental callback with chunk="" to signal finalization.
|
|
471
|
-
* Does NOT call onMessagesChange — the caller is responsible for that.
|
|
472
501
|
* Returns true if a block was finalized.
|
|
473
502
|
*/
|
|
474
503
|
finalizeStreamingBlock(lastMessage, type) {
|
|
@@ -552,7 +581,6 @@ export class MessageManager {
|
|
|
552
581
|
stage: "streaming",
|
|
553
582
|
});
|
|
554
583
|
// Note: Subagent-specific callbacks are now handled by SubagentManager
|
|
555
|
-
this.callbacks.onMessagesChange?.([...this.messages]); // Still need to notify of changes
|
|
556
584
|
}
|
|
557
585
|
/**
|
|
558
586
|
* Update the current assistant message reasoning during streaming
|
|
@@ -599,7 +627,6 @@ export class MessageManager {
|
|
|
599
627
|
accumulated: newAccumulatedReasoning,
|
|
600
628
|
stage: "streaming",
|
|
601
629
|
});
|
|
602
|
-
this.callbacks.onMessagesChange?.([...this.messages]); // Still need to notify of changes
|
|
603
630
|
}
|
|
604
631
|
/**
|
|
605
632
|
* Finalize streaming text/reasoning blocks by setting their stage to "end".
|
|
@@ -612,11 +639,8 @@ export class MessageManager {
|
|
|
612
639
|
const lastMessage = this.messages[this.messages.length - 1];
|
|
613
640
|
if (lastMessage.role !== "assistant")
|
|
614
641
|
return;
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
if (textFinalized || reasoningFinalized) {
|
|
618
|
-
this.callbacks.onMessagesChange?.([...this.messages]);
|
|
619
|
-
}
|
|
642
|
+
this.finalizeStreamingBlock(lastMessage, "text");
|
|
643
|
+
this.finalizeStreamingBlock(lastMessage, "reasoning");
|
|
620
644
|
}
|
|
621
645
|
/**
|
|
622
646
|
* Finalize any tool blocks still in a non-terminal stage (start/streaming/running)
|
|
@@ -310,16 +310,8 @@ export class PermissionManager {
|
|
|
310
310
|
};
|
|
311
311
|
}
|
|
312
312
|
}
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
// must still prompt the user, matching Claude Code's requiresUserInteraction behavior.
|
|
316
|
-
if (context.permissionMode === "bypassPermissions") {
|
|
317
|
-
const requiresUserInteraction = context.toolName === ASK_USER_QUESTION_TOOL_NAME;
|
|
318
|
-
if (!requiresUserInteraction) {
|
|
319
|
-
return { behavior: "allow" };
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
// 1.0 Check worktree safety for Write and Edit tools
|
|
313
|
+
// Check worktree safety for Write and Edit tools — unconditional safety
|
|
314
|
+
// check, applied regardless of permission mode (same as read-before-edit).
|
|
323
315
|
// Support both CLI -w sessions (container-registered) and EnterWorktree mid-session
|
|
324
316
|
// (per-agent WorktreeSession stored in this session's container)
|
|
325
317
|
const worktreeSession = this.container.get("WorktreeSession");
|
|
@@ -371,7 +363,17 @@ export class PermissionManager {
|
|
|
371
363
|
}
|
|
372
364
|
}
|
|
373
365
|
}
|
|
374
|
-
//
|
|
366
|
+
// If bypassPermissions mode, always allow
|
|
367
|
+
// Exception: tools that require user interaction (e.g. AskUserQuestion)
|
|
368
|
+
// must still prompt the user, matching Claude Code's requiresUserInteraction behavior.
|
|
369
|
+
// Worktree safety check above runs unconditionally, so bypass never skips it.
|
|
370
|
+
if (context.permissionMode === "bypassPermissions") {
|
|
371
|
+
const requiresUserInteraction = context.toolName === ASK_USER_QUESTION_TOOL_NAME;
|
|
372
|
+
if (!requiresUserInteraction) {
|
|
373
|
+
return { behavior: "allow" };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// If acceptEdits mode, allow Edit, Write, and mkdir in safe zone
|
|
375
377
|
if (context.permissionMode === "acceptEdits") {
|
|
376
378
|
const autoAcceptedTools = [EDIT_TOOL_NAME, WRITE_TOOL_NAME];
|
|
377
379
|
if (autoAcceptedTools.includes(context.toolName)) {
|
|
@@ -153,4 +153,10 @@ export declare class SubagentManager {
|
|
|
153
153
|
* Extracted to reuse in both create and restore flows
|
|
154
154
|
*/
|
|
155
155
|
private createSubagentCallbacks;
|
|
156
|
+
/**
|
|
157
|
+
* Pull the latest messages from the subagent instance's MessageManager and
|
|
158
|
+
* refresh the instance's cached messages, usedTools, onUpdate and the
|
|
159
|
+
* onSubagentMessagesChange forwarding. Triggered by incremental callbacks.
|
|
160
|
+
*/
|
|
161
|
+
private refreshSubagentState;
|
|
156
162
|
}
|