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
|
@@ -62,6 +62,22 @@ import {
|
|
|
62
62
|
import { logOTelEvent } from "../telemetry/events.js";
|
|
63
63
|
import type { BackgroundTask } from "../types/processes.js";
|
|
64
64
|
|
|
65
|
+
/** Result of a fork-path agent loop (compaction or auto-memory extraction). */
|
|
66
|
+
interface ForkLoopResult {
|
|
67
|
+
content?: string;
|
|
68
|
+
usage?: {
|
|
69
|
+
prompt_tokens: number;
|
|
70
|
+
completion_tokens: number;
|
|
71
|
+
total_tokens: number;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Max turns for the compaction fork: the model should summarize, not act. */
|
|
76
|
+
const MAX_FORK_TURNS = 3;
|
|
77
|
+
|
|
78
|
+
/** Max turns for the auto-memory extraction fork. */
|
|
79
|
+
const MAX_AUTO_MEMORY_FORK_TURNS = 5;
|
|
80
|
+
|
|
65
81
|
// Truncate text to `max` chars and append a "… [+N chars]" marker when exceeded.
|
|
66
82
|
// Used for background_tasks description/command fields (≤1000 chars per spec FR-063).
|
|
67
83
|
function truncateWithMarker(text: string, max: number): string {
|
|
@@ -214,9 +230,27 @@ export class AIManager {
|
|
|
214
230
|
return this.container.get<ConfigurationService>("ConfigurationService")!;
|
|
215
231
|
}
|
|
216
232
|
|
|
233
|
+
/**
|
|
234
|
+
* OS env merged with the per-session env snapshot. Falls back to process.env
|
|
235
|
+
* when ConfigurationService is absent or its getMergedEnv is missing (e.g. in
|
|
236
|
+
* unit tests with partial mocks), so hook-context env construction never
|
|
237
|
+
* throws. Use this (not the non-null `configurationService` getter) when
|
|
238
|
+
* building hook context env.
|
|
239
|
+
*/
|
|
240
|
+
private get mergedEnv(): Record<string, string> {
|
|
241
|
+
return (
|
|
242
|
+
this.container
|
|
243
|
+
.get<ConfigurationService>("ConfigurationService")
|
|
244
|
+
?.getMergedEnv?.() ?? (process.env as Record<string, string>)
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
217
248
|
// Getter methods for accessing dynamic configuration
|
|
218
249
|
public getGatewayConfig(): GatewayConfig {
|
|
219
|
-
return
|
|
250
|
+
return {
|
|
251
|
+
...this.configurationService.resolveGatewayConfig(),
|
|
252
|
+
sessionId: this.messageManager.getSessionId(),
|
|
253
|
+
};
|
|
220
254
|
}
|
|
221
255
|
|
|
222
256
|
public getModelConfig(): ModelConfig {
|
|
@@ -676,8 +710,7 @@ export class AIManager {
|
|
|
676
710
|
} catch (compactError) {
|
|
677
711
|
this.consecutiveCompactionFailures++;
|
|
678
712
|
logger?.error(
|
|
679
|
-
`Failed to compact messages (${this.consecutiveCompactionFailures} consecutive)
|
|
680
|
-
compactError,
|
|
713
|
+
`Failed to compact messages (${this.consecutiveCompactionFailures} consecutive): ${compactError instanceof Error ? compactError.message : String(compactError)}`,
|
|
681
714
|
);
|
|
682
715
|
this.messageManager.addErrorBlock(
|
|
683
716
|
`Failed to compact conversation history: ${compactError instanceof Error ? compactError.message : String(compactError)}. You may encounter context limit issues.`,
|
|
@@ -727,27 +760,26 @@ export class AIManager {
|
|
|
727
760
|
}
|
|
728
761
|
|
|
729
762
|
/**
|
|
730
|
-
* Fork-path
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
* another turn. Returns undefined content when the model never
|
|
736
|
-
* text; the caller treats that as a
|
|
763
|
+
* Fork-path loop: run a bounded agent loop over a copy of the conversation
|
|
764
|
+
* using the same system prompt, tools, model, and generation params as the
|
|
765
|
+
* main loop, so the forked request prefix matches exactly and the prompt
|
|
766
|
+
* cache is reused. A `canUseTool` gate decides whether each tool call
|
|
767
|
+
* executes locally (with a stripped context) or is denied and fed back to
|
|
768
|
+
* the model for another turn. Returns undefined content when the model never
|
|
769
|
+
* produces text; the caller treats that as a failure.
|
|
737
770
|
*/
|
|
738
|
-
private async
|
|
771
|
+
private async runForkLoop(
|
|
739
772
|
historyMessages: ChatCompletionMessageParam[],
|
|
740
|
-
|
|
773
|
+
prompt: string,
|
|
774
|
+
options: {
|
|
775
|
+
maxTurns: number;
|
|
776
|
+
/** Gate deciding which tool calls execute locally. When undefined, every tool call is denied. */
|
|
777
|
+
canUseTool?: (name: string, args: Record<string, unknown>) => boolean;
|
|
778
|
+
/** Message fed back to the model when a tool call is denied. */
|
|
779
|
+
deniedToolMessage?: string;
|
|
780
|
+
},
|
|
741
781
|
abortSignal?: AbortSignal,
|
|
742
|
-
): Promise<{
|
|
743
|
-
content?: string;
|
|
744
|
-
usage?: {
|
|
745
|
-
prompt_tokens: number;
|
|
746
|
-
completion_tokens: number;
|
|
747
|
-
total_tokens: number;
|
|
748
|
-
};
|
|
749
|
-
}> {
|
|
750
|
-
const MAX_FORK_TURNS = 3;
|
|
782
|
+
): Promise<ForkLoopResult> {
|
|
751
783
|
const modelConfig = this.getModelConfig();
|
|
752
784
|
const gatewayConfig = this.getGatewayConfig();
|
|
753
785
|
const sessionId = this.messageManager.getSessionId();
|
|
@@ -765,21 +797,22 @@ export class AIManager {
|
|
|
765
797
|
});
|
|
766
798
|
}
|
|
767
799
|
|
|
768
|
-
forkMessages.push({ role: "user", content:
|
|
800
|
+
forkMessages.push({ role: "user", content: prompt });
|
|
769
801
|
|
|
770
802
|
const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
|
|
771
803
|
const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
|
|
772
804
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
805
|
+
// Fresh read-state map so Read/Edit state built up inside the fork never
|
|
806
|
+
// leaks into the main session's dedup and staleness tracking.
|
|
807
|
+
const forkReadFileState = new Map<
|
|
808
|
+
string,
|
|
809
|
+
{ mtime: number; hash: string; offset?: number; limit?: number }
|
|
810
|
+
>();
|
|
811
|
+
|
|
812
|
+
let totalUsage: ForkLoopResult["usage"];
|
|
780
813
|
let content: string | undefined;
|
|
781
814
|
|
|
782
|
-
for (let turn = 0; turn <
|
|
815
|
+
for (let turn = 0; turn < options.maxTurns; turn++) {
|
|
783
816
|
const result = await aiService.callAgent({
|
|
784
817
|
gatewayConfig,
|
|
785
818
|
modelConfig,
|
|
@@ -792,7 +825,7 @@ export class AIManager {
|
|
|
792
825
|
toolChoice: this.toolChoiceOverride,
|
|
793
826
|
// Stream so a slow reasoning model emits first bytes before the
|
|
794
827
|
// gateway's idle timeout fires (non-streaming waits for the full
|
|
795
|
-
//
|
|
828
|
+
// response, which exceeds the timeout on large contexts).
|
|
796
829
|
stream: true,
|
|
797
830
|
});
|
|
798
831
|
|
|
@@ -814,31 +847,329 @@ export class AIManager {
|
|
|
814
847
|
}
|
|
815
848
|
|
|
816
849
|
if (result.tool_calls && result.tool_calls.length > 0) {
|
|
817
|
-
|
|
818
|
-
|
|
850
|
+
const functionCalls = result.tool_calls.filter(
|
|
851
|
+
(tc) => tc.type === "function",
|
|
852
|
+
);
|
|
853
|
+
if (functionCalls.length === 0) break;
|
|
854
|
+
|
|
819
855
|
forkMessages.push({
|
|
820
856
|
role: "assistant",
|
|
821
857
|
content: result.content ?? null,
|
|
822
|
-
tool_calls:
|
|
858
|
+
tool_calls: functionCalls,
|
|
823
859
|
});
|
|
824
|
-
|
|
860
|
+
|
|
861
|
+
for (const toolCall of functionCalls) {
|
|
862
|
+
const name = toolCall.function?.name || "";
|
|
863
|
+
const args = this.parseForkToolArgs(toolCall.function?.arguments);
|
|
864
|
+
let toolContent: string;
|
|
865
|
+
if (options.canUseTool && options.canUseTool(name, args)) {
|
|
866
|
+
toolContent = await this.executeForkTool(
|
|
867
|
+
name,
|
|
868
|
+
args,
|
|
869
|
+
workdir,
|
|
870
|
+
sessionId,
|
|
871
|
+
abortSignal,
|
|
872
|
+
forkReadFileState,
|
|
873
|
+
);
|
|
874
|
+
} else {
|
|
875
|
+
toolContent =
|
|
876
|
+
options.deniedToolMessage ??
|
|
877
|
+
"Tool use is not allowed in this context";
|
|
878
|
+
}
|
|
825
879
|
forkMessages.push({
|
|
826
880
|
role: "tool",
|
|
827
881
|
tool_call_id: toolCall.id,
|
|
828
|
-
content:
|
|
882
|
+
content: toolContent,
|
|
829
883
|
});
|
|
830
884
|
}
|
|
831
885
|
continue;
|
|
832
886
|
}
|
|
833
887
|
|
|
834
888
|
// Neither text nor tool calls: retrying the identical request is
|
|
835
|
-
// pointless, bail out and let the caller fail
|
|
889
|
+
// pointless, bail out and let the caller fail.
|
|
836
890
|
break;
|
|
837
891
|
}
|
|
838
892
|
|
|
839
893
|
return { content, usage: totalUsage };
|
|
840
894
|
}
|
|
841
895
|
|
|
896
|
+
/**
|
|
897
|
+
* Fork-path compaction: deny all tool calls locally (the model is told to
|
|
898
|
+
* summarize, not act) and feed the rejections back for another turn.
|
|
899
|
+
*/
|
|
900
|
+
private async runCompactFork(
|
|
901
|
+
historyMessages: ChatCompletionMessageParam[],
|
|
902
|
+
compactPrompt: string,
|
|
903
|
+
abortSignal?: AbortSignal,
|
|
904
|
+
): Promise<ForkLoopResult> {
|
|
905
|
+
return this.runForkLoop(
|
|
906
|
+
historyMessages,
|
|
907
|
+
compactPrompt,
|
|
908
|
+
{
|
|
909
|
+
maxTurns: MAX_FORK_TURNS,
|
|
910
|
+
deniedToolMessage: "Tool use is not allowed during compaction",
|
|
911
|
+
},
|
|
912
|
+
abortSignal,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Auto-memory extraction via the perfect fork: the extraction prompt is run
|
|
918
|
+
* against the same request prefix as the main conversation (same system
|
|
919
|
+
* prompt, tools, model, and message history) so the prompt cache is reused.
|
|
920
|
+
* Gate-approved tools execute locally in a stripped context; everything else
|
|
921
|
+
* is denied. Usage is reported with operation_type "agent" so extraction
|
|
922
|
+
* token costs stay visible in session accounting.
|
|
923
|
+
*/
|
|
924
|
+
public async runAutoMemoryFork(
|
|
925
|
+
messages: Message[],
|
|
926
|
+
prompt: string,
|
|
927
|
+
options: {
|
|
928
|
+
canUseTool: (name: string, args: Record<string, unknown>) => boolean;
|
|
929
|
+
deniedToolMessage?: string;
|
|
930
|
+
maxTurns?: number;
|
|
931
|
+
},
|
|
932
|
+
abortSignal?: AbortSignal,
|
|
933
|
+
): Promise<ForkLoopResult> {
|
|
934
|
+
const modelConfig = this.getModelConfig();
|
|
935
|
+
const historyMessages = convertMessagesForAPI(messages, {
|
|
936
|
+
supportsVision: supportsVision(modelConfig.capabilities),
|
|
937
|
+
});
|
|
938
|
+
// Give the fork a real signal even when the caller has none, so tools
|
|
939
|
+
// (e.g. Bash's foreground path) always receive a well-formed context.
|
|
940
|
+
const signal = abortSignal ?? new AbortController().signal;
|
|
941
|
+
const result = await this.runForkLoop(
|
|
942
|
+
historyMessages,
|
|
943
|
+
prompt,
|
|
944
|
+
{
|
|
945
|
+
maxTurns: options.maxTurns ?? MAX_AUTO_MEMORY_FORK_TURNS,
|
|
946
|
+
canUseTool: options.canUseTool,
|
|
947
|
+
deniedToolMessage: options.deniedToolMessage,
|
|
948
|
+
},
|
|
949
|
+
signal,
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
if (result.usage && this.callbacks?.onUsageAdded) {
|
|
953
|
+
this.callbacks.onUsageAdded({
|
|
954
|
+
...result.usage,
|
|
955
|
+
model: modelConfig.model,
|
|
956
|
+
operation_type: "agent",
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
return result;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Parse a fork tool call's JSON arguments, recovering truncated JSON the
|
|
964
|
+
* same way the main loop does. Unparseable arguments fall back to `{}` and
|
|
965
|
+
* are rejected by the gate or the tool's own parameter validation.
|
|
966
|
+
*/
|
|
967
|
+
private parseForkToolArgs(
|
|
968
|
+
argsString: string | undefined,
|
|
969
|
+
): Record<string, unknown> {
|
|
970
|
+
if (!argsString?.trim()) return {};
|
|
971
|
+
try {
|
|
972
|
+
return JSON.parse(argsString) as Record<string, unknown>;
|
|
973
|
+
} catch {
|
|
974
|
+
try {
|
|
975
|
+
return JSON.parse(recoverTruncatedJson(argsString)) as Record<
|
|
976
|
+
string,
|
|
977
|
+
unknown
|
|
978
|
+
>;
|
|
979
|
+
} catch {
|
|
980
|
+
return {};
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Execute a single tool call inside a fork with a stripped context: no
|
|
987
|
+
* permission manager (never prompts the user), no message manager (no
|
|
988
|
+
* conditional-rule triggering), no messageId (no file-history snapshots),
|
|
989
|
+
* and no background task manager (commands run in the foreground). Only
|
|
990
|
+
* gate-approved tool names reach this path.
|
|
991
|
+
*/
|
|
992
|
+
private async executeForkTool(
|
|
993
|
+
name: string,
|
|
994
|
+
args: Record<string, unknown>,
|
|
995
|
+
workdir: string,
|
|
996
|
+
sessionId: string | undefined,
|
|
997
|
+
abortSignal: AbortSignal | undefined,
|
|
998
|
+
readFileState: ToolContext["readFileState"],
|
|
999
|
+
): Promise<string> {
|
|
1000
|
+
const plugin = this.toolManager.getTools().find((t) => t.name === name);
|
|
1001
|
+
if (!plugin) {
|
|
1002
|
+
return `Tool '${name}' not found`;
|
|
1003
|
+
}
|
|
1004
|
+
const context: ToolContext = {
|
|
1005
|
+
abortSignal,
|
|
1006
|
+
workdir,
|
|
1007
|
+
originalWorkdir: this.originalWorkdir,
|
|
1008
|
+
sessionId,
|
|
1009
|
+
taskManager: this.taskManager,
|
|
1010
|
+
readFileState,
|
|
1011
|
+
onShortResultUpdate: () => {},
|
|
1012
|
+
onResultUpdate: () => {},
|
|
1013
|
+
onCwdChange: () => {},
|
|
1014
|
+
};
|
|
1015
|
+
try {
|
|
1016
|
+
const result = await plugin.execute(args, context);
|
|
1017
|
+
if (result.content) return result.content;
|
|
1018
|
+
if (result.error) return `Error: ${result.error}`;
|
|
1019
|
+
return "";
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1022
|
+
logger?.error(`Fork tool execution failed for ${name}:`, error);
|
|
1023
|
+
return `Tool execution failed: ${message}`;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Fork-path side question ("/btw"): run a single-turn fork of the
|
|
1029
|
+
* conversation using the same system prompt, tools, model, and generation
|
|
1030
|
+
* params as the main loop, so the forked request prefix matches exactly and
|
|
1031
|
+
* the prompt cache is reused. The in-progress assistant message (if any) is
|
|
1032
|
+
* stripped so the fork starts from the last completed request prefix. Tools
|
|
1033
|
+
* are never executed — the wrapped question instructs the model to answer
|
|
1034
|
+
* directly; an attempted tool call is surfaced as an error string.
|
|
1035
|
+
*/
|
|
1036
|
+
async runBtwFork(
|
|
1037
|
+
question: string,
|
|
1038
|
+
abortSignal?: AbortSignal,
|
|
1039
|
+
onContent?: (content: string) => void,
|
|
1040
|
+
onReasoning?: (content: string) => void,
|
|
1041
|
+
): Promise<{ content?: string; error?: string }> {
|
|
1042
|
+
const modelConfig = this.getModelConfig();
|
|
1043
|
+
const gatewayConfig = this.getGatewayConfig();
|
|
1044
|
+
const sessionId = this.messageManager.getSessionId();
|
|
1045
|
+
const workdir = this.getWorkdir();
|
|
1046
|
+
|
|
1047
|
+
const rawMessages = this.messageManager.getMessages();
|
|
1048
|
+
|
|
1049
|
+
// Strip the in-progress assistant message (a block still in "streaming"
|
|
1050
|
+
// stage) so the fork's request prefix matches the last completed
|
|
1051
|
+
// main-loop request and the prompt cache is reused.
|
|
1052
|
+
const lastMessage = rawMessages[rawMessages.length - 1];
|
|
1053
|
+
const hasInProgressMessage =
|
|
1054
|
+
lastMessage?.role === "assistant" &&
|
|
1055
|
+
lastMessage.blocks.some(
|
|
1056
|
+
(b) => "stage" in b && (b as { stage?: string }).stage === "streaming",
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
const forkMessages: ChatCompletionMessageParam[] = convertMessagesForAPI(
|
|
1060
|
+
hasInProgressMessage ? rawMessages.slice(0, -1) : rawMessages,
|
|
1061
|
+
{ supportsVision: supportsVision(modelConfig.capabilities) },
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
// Mirror the main loop's memory injection so the request prefix matches.
|
|
1065
|
+
const { prependContent } =
|
|
1066
|
+
await this.messageManager.getMemoryForInjection();
|
|
1067
|
+
if (prependContent.trim()) {
|
|
1068
|
+
forkMessages.unshift({
|
|
1069
|
+
role: "user",
|
|
1070
|
+
content: wrapInSystemReminder(prependContent),
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Wrap the question with the side-question instructions (verbatim
|
|
1075
|
+
// Claude Code sideQuestion.ts) so the model answers directly.
|
|
1076
|
+
const wrappedQuestion = `<system-reminder>This is a side question from the user. You must answer this question directly in a single response.
|
|
1077
|
+
|
|
1078
|
+
IMPORTANT CONTEXT:
|
|
1079
|
+
- You are a separate, lightweight agent spawned to answer this one question
|
|
1080
|
+
- The main agent is NOT interrupted - it continues working independently in the background
|
|
1081
|
+
- You share the conversation context but are a completely separate instance
|
|
1082
|
+
- Do NOT reference being interrupted or what you were "previously doing" - that framing is incorrect
|
|
1083
|
+
|
|
1084
|
+
CRITICAL CONSTRAINTS:
|
|
1085
|
+
- You have NO tools available - you cannot read files, run commands, search, or take any actions
|
|
1086
|
+
- This is a one-off response - there will be no follow-up turns
|
|
1087
|
+
- You can ONLY provide information based on what you already know from the conversation context
|
|
1088
|
+
- NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action
|
|
1089
|
+
- If you don't know the answer, say so - do not offer to look it up or investigate
|
|
1090
|
+
|
|
1091
|
+
Simply answer the question with the information you have.</system-reminder>
|
|
1092
|
+
|
|
1093
|
+
${question}`;
|
|
1094
|
+
forkMessages.push({ role: "user", content: wrappedQuestion });
|
|
1095
|
+
|
|
1096
|
+
const { toolsConfig, filteredToolPlugins } = this.resolveFilteredTools();
|
|
1097
|
+
const systemPrompt = await this.buildMainSystemPrompt(filteredToolPlugins);
|
|
1098
|
+
|
|
1099
|
+
try {
|
|
1100
|
+
// Surface partial output to the caller (e.g. the /btw overlay's
|
|
1101
|
+
// streaming display) as it arrives. Reasoning chunks from thinking
|
|
1102
|
+
// models stream through a separate channel when the caller supplies
|
|
1103
|
+
// one (webview hosts distinguish thinking from content so the panel
|
|
1104
|
+
// can drop thinking text once content starts); otherwise they fall
|
|
1105
|
+
// back to the content channel (CLI overlay mixes both).
|
|
1106
|
+
const streamToOverlay = (text: string) => {
|
|
1107
|
+
if (text.trim()) {
|
|
1108
|
+
onContent?.(text);
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
const streamReasoning = (text: string) => {
|
|
1112
|
+
if (text.trim()) {
|
|
1113
|
+
if (onReasoning) {
|
|
1114
|
+
onReasoning(text);
|
|
1115
|
+
} else {
|
|
1116
|
+
onContent?.(text);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
const result = await aiService.callAgent({
|
|
1121
|
+
gatewayConfig,
|
|
1122
|
+
modelConfig,
|
|
1123
|
+
messages: forkMessages,
|
|
1124
|
+
sessionId,
|
|
1125
|
+
abortSignal,
|
|
1126
|
+
workdir,
|
|
1127
|
+
tools: toolsConfig,
|
|
1128
|
+
systemPrompt,
|
|
1129
|
+
toolChoice: this.toolChoiceOverride,
|
|
1130
|
+
// Stream so a slow reasoning model emits first bytes before the
|
|
1131
|
+
// gateway's idle timeout fires (same rationale as runCompactFork).
|
|
1132
|
+
stream: true,
|
|
1133
|
+
onContentUpdate: streamToOverlay,
|
|
1134
|
+
onReasoningUpdate: streamReasoning,
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
if (result.content?.trim()) {
|
|
1138
|
+
return { content: result.content };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// A thinking model may emit only reasoning content (e.g. the stream
|
|
1142
|
+
// is truncated before the final answer); surface that instead of
|
|
1143
|
+
// falling through to "No response received".
|
|
1144
|
+
if (result.reasoning_content?.trim()) {
|
|
1145
|
+
return { content: result.reasoning_content };
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (result.tool_calls && result.tool_calls.length > 0) {
|
|
1149
|
+
const firstFunctionCall = result.tool_calls.find(
|
|
1150
|
+
(call) => call.type === "function",
|
|
1151
|
+
);
|
|
1152
|
+
const toolName = firstFunctionCall?.function?.name ?? "a tool";
|
|
1153
|
+
return {
|
|
1154
|
+
error: `(The model tried to call ${toolName} instead of answering directly. Try rephrasing or ask in the main conversation.)`,
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Neither text nor tool calls.
|
|
1159
|
+
return { error: "No response received" };
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
// aiService.callAgent converts AbortError into a plain Error, so the
|
|
1162
|
+
// abort is detected via the signal itself; rethrow so the UI can
|
|
1163
|
+
// silently dismiss instead of showing an error.
|
|
1164
|
+
if (abortSignal?.aborted) {
|
|
1165
|
+
throw error;
|
|
1166
|
+
}
|
|
1167
|
+
return {
|
|
1168
|
+
error: `(API error: ${error instanceof Error ? error.message : String(error)})`,
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
842
1173
|
/**
|
|
843
1174
|
* Build post-compact context restoration content.
|
|
844
1175
|
* Restores file reads, working directory, plan mode, skills, and background tasks.
|
|
@@ -1132,6 +1463,12 @@ export class AIManager {
|
|
|
1132
1463
|
systemPrompt: mainSystemPrompt, // Pass custom system prompt
|
|
1133
1464
|
maxTokens: maxTokens, // Pass max tokens override
|
|
1134
1465
|
toolChoice: this.toolChoiceOverride, // Pass tool_choice override
|
|
1466
|
+
// Fast-model subagents send disable-thinking params only when
|
|
1467
|
+
// explicitly configured (never in the agent loop).
|
|
1468
|
+
disableThinkingOptions:
|
|
1469
|
+
this.modelOverride === "fastModel"
|
|
1470
|
+
? this.getModelConfig().disableThinkingOptions
|
|
1471
|
+
: undefined,
|
|
1135
1472
|
};
|
|
1136
1473
|
|
|
1137
1474
|
// Prepend: AGENTS.md + user memory + unconditional rules as system-reminder
|
|
@@ -1560,77 +1897,19 @@ export class AIManager {
|
|
|
1560
1897
|
}
|
|
1561
1898
|
}
|
|
1562
1899
|
|
|
1563
|
-
//
|
|
1564
|
-
const
|
|
1565
|
-
? this.container.get<import("./goalManager.js").GoalManager>(
|
|
1566
|
-
"GoalManager",
|
|
1567
|
-
)
|
|
1568
|
-
: undefined;
|
|
1569
|
-
|
|
1570
|
-
let goalContinuing = false;
|
|
1571
|
-
|
|
1572
|
-
if (goalManager?.isGoalActive() && !this.subagentType) {
|
|
1573
|
-
// 1. Increment turn count and check circuit breakers
|
|
1574
|
-
goalManager.incrementTurnCount();
|
|
1575
|
-
const circuitBreaker = goalManager.checkCircuitBreakers();
|
|
1576
|
-
|
|
1577
|
-
if (circuitBreaker) {
|
|
1578
|
-
goalManager.clearGoal();
|
|
1579
|
-
logger?.info(`[Goal] ${circuitBreaker}`);
|
|
1580
|
-
this.messageManager.addUserMessage({
|
|
1581
|
-
content: `<system-reminder>${circuitBreaker}</system-reminder>`,
|
|
1582
|
-
isMeta: true,
|
|
1583
|
-
});
|
|
1584
|
-
// Fall through to normal Stop hooks on the final turn
|
|
1585
|
-
} else {
|
|
1586
|
-
// 2. Evaluate goal
|
|
1587
|
-
const evaluation = await goalManager.evaluateGoal(
|
|
1588
|
-
abortController.signal,
|
|
1589
|
-
);
|
|
1590
|
-
|
|
1591
|
-
if (evaluation.isMet) {
|
|
1592
|
-
goalManager.clearGoal();
|
|
1593
|
-
logger?.info(`[Goal] Goal achieved: ${evaluation.reason}`);
|
|
1594
|
-
this.messageManager.addUserMessage({
|
|
1595
|
-
content: `<system-reminder>Goal achieved: ${evaluation.reason}</system-reminder>`,
|
|
1596
|
-
isMeta: true,
|
|
1597
|
-
});
|
|
1598
|
-
// Fall through to normal Stop hooks on the final turn
|
|
1599
|
-
} else {
|
|
1600
|
-
const goal = goalManager.getGoal()!;
|
|
1601
|
-
goal.lastReason = evaluation.reason;
|
|
1602
|
-
logger?.info(`[Goal] Not yet met: ${evaluation.reason}`);
|
|
1603
|
-
this.messageManager.addUserMessage({
|
|
1604
|
-
content: `<system-reminder>Goal not yet met: ${evaluation.reason}. Continue working toward: ${goal.condition}</system-reminder>`,
|
|
1605
|
-
isMeta: true,
|
|
1606
|
-
});
|
|
1607
|
-
// Keep loading state active to prevent UI flicker
|
|
1608
|
-
this.setIsLoading(true);
|
|
1609
|
-
goalContinuing = true;
|
|
1610
|
-
// Restart outer loop to continue goal pursuit
|
|
1611
|
-
shouldRestart = true;
|
|
1612
|
-
turnOffset = 0;
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
// Skip Stop hooks when goal evaluator is continuing the conversation
|
|
1618
|
-
if (goalContinuing) {
|
|
1619
|
-
// Goal evaluator supersedes Stop hooks
|
|
1620
|
-
} else {
|
|
1621
|
-
const shouldContinue = await this.executeStopHooks();
|
|
1900
|
+
// Execute Stop hooks
|
|
1901
|
+
const shouldContinue = await this.executeStopHooks();
|
|
1622
1902
|
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1903
|
+
// If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
|
|
1904
|
+
// restart the AI conversation cycle
|
|
1905
|
+
if (shouldContinue) {
|
|
1906
|
+
logger?.info(
|
|
1907
|
+
`${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`,
|
|
1908
|
+
);
|
|
1629
1909
|
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
}
|
|
1910
|
+
// Restart the conversation to let AI fix the issues
|
|
1911
|
+
shouldRestart = true;
|
|
1912
|
+
turnOffset = 0;
|
|
1634
1913
|
}
|
|
1635
1914
|
}
|
|
1636
1915
|
|
|
@@ -1739,7 +2018,7 @@ export class AIManager {
|
|
|
1739
2018
|
lastAssistantMessage: lastAssistantText, // Stop/SubagentStop: last assistant message text
|
|
1740
2019
|
// Stop hooks don't need toolName, toolInput, toolResponse, or userPrompt
|
|
1741
2020
|
env: Object.fromEntries(
|
|
1742
|
-
Object.entries(
|
|
2021
|
+
Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
|
|
1743
2022
|
) as Record<string, string>, // Include environment variables
|
|
1744
2023
|
};
|
|
1745
2024
|
|
|
@@ -1933,7 +2212,7 @@ export class AIManager {
|
|
|
1933
2212
|
const sessionId = this.messageManager.getSessionId();
|
|
1934
2213
|
const transcriptPath = this.messageManager.getTranscriptPath();
|
|
1935
2214
|
const env = Object.fromEntries(
|
|
1936
|
-
Object.entries(
|
|
2215
|
+
Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
|
|
1937
2216
|
) as Record<string, string>;
|
|
1938
2217
|
await this.hookManager.executeCwdChangedHooks(
|
|
1939
2218
|
oldCwd,
|
|
@@ -2028,7 +2307,7 @@ export class AIManager {
|
|
|
2028
2307
|
toolInput,
|
|
2029
2308
|
subagentType: this.subagentType, // Include subagent type in hook context
|
|
2030
2309
|
env: Object.fromEntries(
|
|
2031
|
-
Object.entries(
|
|
2310
|
+
Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
|
|
2032
2311
|
) as Record<string, string>, // Include environment variables
|
|
2033
2312
|
};
|
|
2034
2313
|
|
|
@@ -2104,7 +2383,7 @@ export class AIManager {
|
|
|
2104
2383
|
subagentType: this.subagentType, // Include subagent type in hook context
|
|
2105
2384
|
planFilePath: this.permissionManager?.getPlanFilePath(),
|
|
2106
2385
|
env: Object.fromEntries(
|
|
2107
|
-
Object.entries(
|
|
2386
|
+
Object.entries(this.mergedEnv).filter((e) => e[1] !== undefined),
|
|
2108
2387
|
) as Record<string, string>, // Include environment variables
|
|
2109
2388
|
};
|
|
2110
2389
|
|
|
@@ -8,6 +8,7 @@ import { logger } from "../utils/globalLogger.js";
|
|
|
8
8
|
import { Container } from "../utils/container.js";
|
|
9
9
|
import { MessageQueue } from "./messageQueue.js";
|
|
10
10
|
import { resolveShellPath } from "../utils/shellResolver.js";
|
|
11
|
+
import type { ConfigurationService } from "../services/configurationService.js";
|
|
11
12
|
|
|
12
13
|
export interface BackgroundTaskManagerCallbacks {
|
|
13
14
|
onBackgroundTasksChange?: (tasks: BackgroundTask[]) => void;
|
|
@@ -32,6 +33,19 @@ export class BackgroundTaskManager {
|
|
|
32
33
|
this.workdir = options.workdir;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Merged env (OS env overlaid with this session's settings snapshot) for
|
|
38
|
+
* background-task subprocesses, so settings `env` vars reach them without
|
|
39
|
+
* polluting other sessions in one `wave --stdio` process.
|
|
40
|
+
*/
|
|
41
|
+
private get sessionEnv(): Record<string, string> {
|
|
42
|
+
return (
|
|
43
|
+
this.container
|
|
44
|
+
.get<ConfigurationService>("ConfigurationService")
|
|
45
|
+
?.getMergedEnv?.() ?? (process.env as Record<string, string>)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
/**
|
|
36
50
|
* Fire the onBackgroundTasksChange callback so UI consumers refresh.
|
|
37
51
|
* Public so other managers (e.g. WorkflowManager) can trigger a refresh
|
|
@@ -73,6 +87,7 @@ export class BackgroundTaskManager {
|
|
|
73
87
|
cwd: cwd ?? this.workdir,
|
|
74
88
|
env: {
|
|
75
89
|
...process.env,
|
|
90
|
+
...this.sessionEnv,
|
|
76
91
|
},
|
|
77
92
|
});
|
|
78
93
|
|
|
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "child_process";
|
|
|
2
2
|
import type { MessageManager } from "./messageManager.js";
|
|
3
3
|
import { Container } from "../utils/container.js";
|
|
4
4
|
import { resolveShellPath } from "../utils/shellResolver.js";
|
|
5
|
+
import type { ConfigurationService } from "../services/configurationService.js";
|
|
5
6
|
|
|
6
7
|
export interface BangManagerOptions {
|
|
7
8
|
workdir: string;
|
|
@@ -29,6 +30,19 @@ export class BangManager {
|
|
|
29
30
|
return this.container.get<MessageManager>("MessageManager")!;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Merged env (OS env overlaid with this session's settings snapshot) for
|
|
35
|
+
* bang-command subprocesses, so settings `env` vars reach them without
|
|
36
|
+
* polluting other sessions in one `wave --stdio` process.
|
|
37
|
+
*/
|
|
38
|
+
private get sessionEnv(): Record<string, string> {
|
|
39
|
+
return (
|
|
40
|
+
this.container
|
|
41
|
+
.get<ConfigurationService>("ConfigurationService")
|
|
42
|
+
?.getMergedEnv?.() ?? (process.env as Record<string, string>)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
private setCommandRunning(isRunning: boolean): void {
|
|
33
47
|
this.isCommandRunning = isRunning;
|
|
34
48
|
this.onCommandRunningChange?.(isRunning);
|
|
@@ -51,6 +65,7 @@ export class BangManager {
|
|
|
51
65
|
cwd: this.workdir,
|
|
52
66
|
env: {
|
|
53
67
|
...process.env,
|
|
68
|
+
...this.sessionEnv,
|
|
54
69
|
},
|
|
55
70
|
});
|
|
56
71
|
|