skyloom 1.13.4 → 1.13.6

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/package.json CHANGED
@@ -1,68 +1,68 @@
1
- {
2
- "name": "skyloom",
3
- "version": "1.13.4",
4
- "description": "天空织机 Skyloom — 6 weather-themed AI agents: Fog, Rain, Frost, Snow, Dew, Fair",
5
- "preferGlobal": true,
6
- "type": "commonjs",
7
- "main": "./dist/index.js",
8
- "bin": {
9
- "sky": "dist/cli/main.js"
10
- },
11
- "scripts": {
12
- "build": "tsc",
13
- "dev": "tsc --watch",
14
- "start": "node ./dist/cli/main.js",
15
- "chat": "node ./dist/cli/main.js chat",
16
- "task": "node ./dist/cli/main.js task",
17
- "web": "node ./dist/cli/main.js web",
18
- "test": "vitest",
19
- "test:coverage": "vitest --coverage",
20
- "lint": "eslint src --ext .ts",
21
- "type-check": "tsc --noEmit",
22
- "format": "prettier --write \"src/**/*.ts\"",
23
- "pretest": "npm run type-check",
24
- "setup": "node scripts/install.js",
25
- "postinstall": "node scripts/link.js"
26
- },
27
- "keywords": [
28
- "multi-agent",
29
- "llm",
30
- "orchestration",
31
- "ai",
32
- "tool-use"
33
- ],
34
- "author": "Skyloom Team",
35
- "license": "MIT",
36
- "repository": {
37
- "type": "git",
38
- "url": "git+https://github.com/susurrune/skyloom-ts.git"
39
- },
40
- "homepage": "https://github.com/susurrune/skyloom-ts#readme",
41
- "bugs": {
42
- "url": "https://github.com/susurrune/skyloom-ts/issues"
43
- },
44
- "dependencies": {
45
- "axios": "^1.7.7",
46
- "chalk": "^5.3.0",
47
- "commander": "^12.1.0",
48
- "glob": "^11.0.0",
49
- "sql.js": "^1.14.1",
50
- "yaml": "^2.4.1"
51
- },
52
- "devDependencies": {
53
- "@types/glob": "^8.1.0",
54
- "@types/node": "^22.0.0",
55
- "@typescript-eslint/eslint-plugin": "^8.0.0",
56
- "@typescript-eslint/parser": "^8.0.0",
57
- "@vitest/coverage-v8": "^2.0.0",
58
- "@vitest/ui": "^2.0.0",
59
- "eslint": "^9.0.0",
60
- "prettier": "^3.2.0",
61
- "tsx": "^4.7.0",
62
- "typescript": "^5.4.0",
63
- "vitest": "^2.0.0"
64
- },
65
- "engines": {
66
- "node": ">=18.0.0"
67
- }
68
- }
1
+ {
2
+ "name": "skyloom",
3
+ "version": "1.13.6",
4
+ "description": "天空织机 Skyloom — 6 weather-themed AI agents: Fog, Rain, Frost, Snow, Dew, Fair",
5
+ "preferGlobal": true,
6
+ "type": "commonjs",
7
+ "main": "./dist/index.js",
8
+ "bin": {
9
+ "sky": "dist/cli/main.js"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "dev": "tsc --watch",
14
+ "start": "node ./dist/cli/main.js",
15
+ "chat": "node ./dist/cli/main.js chat",
16
+ "task": "node ./dist/cli/main.js task",
17
+ "web": "node ./dist/cli/main.js web",
18
+ "test": "vitest",
19
+ "test:coverage": "vitest --coverage",
20
+ "lint": "eslint src --ext .ts",
21
+ "type-check": "tsc --noEmit",
22
+ "format": "prettier --write \"src/**/*.ts\"",
23
+ "pretest": "npm run type-check",
24
+ "setup": "node scripts/install.js",
25
+ "postinstall": "node scripts/link.js"
26
+ },
27
+ "keywords": [
28
+ "multi-agent",
29
+ "llm",
30
+ "orchestration",
31
+ "ai",
32
+ "tool-use"
33
+ ],
34
+ "author": "Skyloom Team",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/susurrune/skyloom-ts.git"
39
+ },
40
+ "homepage": "https://github.com/susurrune/skyloom-ts#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/susurrune/skyloom-ts/issues"
43
+ },
44
+ "dependencies": {
45
+ "axios": "^1.7.7",
46
+ "chalk": "^5.3.0",
47
+ "commander": "^12.1.0",
48
+ "glob": "^11.0.0",
49
+ "sql.js": "^1.14.1",
50
+ "yaml": "^2.4.1"
51
+ },
52
+ "devDependencies": {
53
+ "@types/glob": "^8.1.0",
54
+ "@types/node": "^22.0.0",
55
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
56
+ "@typescript-eslint/parser": "^8.0.0",
57
+ "@vitest/coverage-v8": "^2.0.0",
58
+ "@vitest/ui": "^2.0.0",
59
+ "eslint": "^9.0.0",
60
+ "prettier": "^3.2.0",
61
+ "tsx": "^4.7.0",
62
+ "typescript": "^5.4.0",
63
+ "vitest": "^2.0.0"
64
+ },
65
+ "engines": {
66
+ "node": ">=18.0.0"
67
+ }
68
+ }
package/src/cli/main.ts CHANGED
@@ -153,8 +153,19 @@ async function streamResponse(agent: any, input: string): Promise<void> {
153
153
  const header = () => { if (!headerShown) { out.write("\n " + chalk.bold.hex(theme.hex)(`${theme.symbol} ${theme.kanji}`) + chalk.hex(theme.hex)(` ${theme.name}`) + "\n\n"); headerShown = true; } };
154
154
  const endBlock = () => { if (renderer) { renderer.flush(); renderer = null; out.write("\n"); } };
155
155
 
156
+ // ── Ctrl-C interrupts this turn (keeps partial output); a 2nd Ctrl-C exits. ──
157
+ const controller = new AbortController();
158
+ let interrupted = false;
159
+ const onSigint = () => {
160
+ if (interrupted) { out.write(chalk.dim("\n 再会。\n")); process.exit(130); }
161
+ interrupted = true;
162
+ controller.abort();
163
+ };
164
+ process.on("SIGINT", onSigint);
165
+
156
166
  try {
157
- for await (const ev of agent.chatStream(input)) {
167
+ for await (const ev of agent.chatStream(input, controller.signal)) {
168
+ if (ev.type === "interrupted") { interrupted = true; continue; }
158
169
  switch (ev.type) {
159
170
  case "reasoning":
160
171
  stopSpinner();
@@ -185,10 +196,15 @@ async function streamResponse(agent: any, input: string): Promise<void> {
185
196
  break;
186
197
  }
187
198
  }
199
+ } catch (e: any) {
200
+ // Abort surfaces here only if the network rejected before a clean stop.
201
+ if (!interrupted && e?.name !== "AbortError") throw e;
188
202
  } finally {
203
+ process.removeListener("SIGINT", onSigint);
189
204
  stopSpinner();
190
205
  endBlock();
191
206
  }
207
+ if (interrupted) out.write(chalk.dim("\n ⊘ 已中断(保留以上内容)\n"));
192
208
  out.write("\n");
193
209
  }
194
210
 
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Anti-loop guard for the agent reasoning loop.
3
+ *
4
+ * Extracted from chatStreamImpl (Phase 3). Holds the per-turn heuristic state
5
+ * (recent response texts, tool-call signatures, tool outcomes + once-only hint
6
+ * flags) and, after each round, returns a decision: zero or more system
7
+ * "hints" to nudge the model, and optionally a hard `stop` (assistant note +
8
+ * a user-visible content line). The agent loop applies the decision — the guard
9
+ * itself has no side effects, which makes every branch unit-testable.
10
+ *
11
+ * A fresh LoopGuard is created per turn (state must not leak across turns).
12
+ */
13
+
14
+ import type { ToolCall } from '../llm';
15
+ import {
16
+ toolCallSignature,
17
+ textSimilarity,
18
+ looksLikeFailedToolResult,
19
+ parseToolArgs,
20
+ SIG_WINDOW,
21
+ SIG_LOOP_HINT,
22
+ SIG_LOOP_HARDSTOP,
23
+ } from '../agent_helpers';
24
+
25
+ /** A hard stop: record `note` as an assistant message, then surface `contentLine`. */
26
+ export interface GuardStop {
27
+ note: string;
28
+ contentLine: string;
29
+ }
30
+
31
+ /** The guard's decision for one round. `hints` apply in order; `stop` ends the turn. */
32
+ export interface GuardDecision {
33
+ hints: string[];
34
+ stop?: GuardStop;
35
+ }
36
+
37
+ /** Minimal shape of a tool execution result the guard inspects. */
38
+ export interface GuardExecResult {
39
+ toolName: string;
40
+ success: boolean;
41
+ result: string;
42
+ }
43
+
44
+ export class LoopGuard {
45
+ private recentResponseTexts: string[] = [];
46
+ private recentToolSigs: string[] = [];
47
+ private recentToolOutcomes: boolean[] = [];
48
+ private searchCount = 0; // cumulative search/fetch calls this turn (not window-bounded)
49
+ private repetitionHintInjected = false;
50
+ private toolLoopHintInjected = false; // shared by tool-signature loop + search-storm
51
+ private stuckHintInjected = false;
52
+
53
+ /**
54
+ * Observe one completed round. Mutates internal state and returns the
55
+ * hints/stop decision. Evaluation order (and the shared hint flag) mirrors
56
+ * the original inline logic exactly.
57
+ */
58
+ observe(
59
+ roundContent: string,
60
+ toolCallsReceived: ToolCall[],
61
+ execResults: Array<GuardExecResult | null>
62
+ ): GuardDecision {
63
+ const hints: string[] = [];
64
+
65
+ // 1. Narration-loop: response too similar to a recent one.
66
+ const normalizedRound = (roundContent || '').trim();
67
+ if (normalizedRound && this.recentResponseTexts.length > 0) {
68
+ const highSim = this.recentResponseTexts.slice(-2).some(prev => textSimilarity(normalizedRound, prev) >= 0.7);
69
+ if (highSim && !this.repetitionHintInjected) {
70
+ hints.push('[Stop narrating] Your last response is highly similar to your previous one. Stop writing prose. Either: (1) emit ONLY the next tool call, or (2) output the final deliverable.');
71
+ this.repetitionHintInjected = true;
72
+ }
73
+ }
74
+ this.recentResponseTexts.push(normalizedRound);
75
+ if (this.recentResponseTexts.length > 3) this.recentResponseTexts.shift();
76
+
77
+ // 2. Tool-signature loop: same call repeated within the window.
78
+ for (const tc of toolCallsReceived) {
79
+ const tName = tc.function.name;
80
+ if (['task_done', 'list_skills', 'use_skill'].includes(tName)) continue;
81
+ if (['web_search', 'fetch_page', 'http_get'].includes(tName)) this.searchCount++;
82
+ const rawArgs = tc.function.arguments;
83
+ const tArgs = typeof rawArgs === 'string' ? parseToolArgs(rawArgs) : rawArgs;
84
+ const sig = toolCallSignature(tName, tArgs);
85
+ if (sig) this.recentToolSigs.push(sig);
86
+ }
87
+ if (this.recentToolSigs.length > SIG_WINDOW) {
88
+ this.recentToolSigs.splice(0, this.recentToolSigs.length - SIG_WINDOW);
89
+ }
90
+ if (this.recentToolSigs.length > 0) {
91
+ const counts = new Map<string, number>();
92
+ for (const s of this.recentToolSigs) counts.set(s, (counts.get(s) || 0) + 1);
93
+ let topSig = '';
94
+ let topCount = 0;
95
+ for (const [s, c] of counts) { if (c > topCount) { topSig = s; topCount = c; } }
96
+ if (topCount >= SIG_LOOP_HINT && !this.toolLoopHintInjected) {
97
+ hints.push(`[Tool loop] You have called \`${topSig}\` ${topCount}x in the last ${this.recentToolSigs.length} tool calls — you are iterating without converging. STOP repeating it.`);
98
+ this.toolLoopHintInjected = true;
99
+ }
100
+ if (topCount >= SIG_LOOP_HARDSTOP) {
101
+ return { hints, stop: { note: `I have repeated \`${topSig}\` ${topCount} times without converging. Stopping.`, contentLine: `\n\n[stuck] tool \`${topSig}\` repeated ${topCount}x — stopping.` } };
102
+ }
103
+ }
104
+
105
+ // 3. Stuck-loop: most/all recent tool calls failed.
106
+ for (const r of execResults) {
107
+ if (!r || r.toolName === 'task_done') continue;
108
+ const failed = !r.success || (typeof r.result === 'string' && looksLikeFailedToolResult(r.result));
109
+ this.recentToolOutcomes.push(!failed);
110
+ // Keep 8 so the "all recent calls failed" (>=8) hard-stop below is reachable.
111
+ if (this.recentToolOutcomes.length > 8) this.recentToolOutcomes.shift();
112
+ }
113
+ if (!this.stuckHintInjected && this.recentToolOutcomes.length >= 5 &&
114
+ this.recentToolOutcomes.filter(Boolean).length <= 1) {
115
+ hints.push('[Recovery hint] Your last several tool calls have mostly failed. Synthesize a partial answer from what worked or ask the user for guidance.');
116
+ this.stuckHintInjected = true;
117
+ }
118
+ if (this.recentToolOutcomes.length >= 8 && this.recentToolOutcomes.every(x => !x)) {
119
+ return { hints, stop: { note: 'Every recent tool call failed. Please give me more context.', contentLine: '\n\n[stuck] every recent tool call failed — stopping.\n' } };
120
+ }
121
+
122
+ // 4. Search-storm: cumulative search/fetch calls this turn (not bounded by
123
+ // SIG_WINDOW, so the >=12 hard-stop is actually reachable).
124
+ if (this.searchCount >= 8 && !this.toolLoopHintInjected) {
125
+ hints.push(`[Search storm] ${this.searchCount} search calls. STOP searching and synthesize.`);
126
+ this.toolLoopHintInjected = true;
127
+ }
128
+ if (this.searchCount >= 12) {
129
+ return { hints, stop: { note: 'Too many search requests. Synthesizing best answer.', contentLine: `\n\n[stuck] excessive web searching (${this.searchCount} calls) — stopping.\n` } };
130
+ }
131
+
132
+ return { hints };
133
+ }
134
+ }
package/src/core/agent.ts CHANGED
@@ -14,22 +14,17 @@ import { Skill, SkillRegistry } from './skill';
14
14
  import { type ToolDefinition, ToolRegistry } from './tool';
15
15
  import {
16
16
  parseToolArgs,
17
- looksLikeFailedToolResult,
18
17
  extractFilePathsFromMessages,
19
18
  enrichResponseWithArtifacts,
20
- toolCallSignature,
21
- textSimilarity,
22
19
  formatArgsParseError,
23
20
  suggestToolNames,
24
21
  toolStatusLabel,
25
22
  synthesizeDelegationSummary,
26
23
  parseExtractedFacts,
27
- SIG_WINDOW,
28
- SIG_LOOP_HINT,
29
- SIG_LOOP_HARDSTOP,
30
24
  } from './agent_helpers';
31
25
  import { selectRelevantTools } from './tool_router';
32
26
  import { getModelInfo } from './catalog';
27
+ import { LoopGuard } from './agent/guard';
33
28
 
34
29
  const log = getLogger('agent');
35
30
 
@@ -708,12 +703,12 @@ export class BaseAgent {
708
703
  }
709
704
  }
710
705
 
711
- async *chatStream(message: string): AsyncGenerator<Record<string, any>> {
706
+ async *chatStream(message: string, signal?: AbortSignal): AsyncGenerator<Record<string, any>> {
712
707
  const activatedNow = this.autoActivateSkills(message);
713
708
  const self = this;
714
709
 
715
710
  try {
716
- for await (const ev of self.chatStreamImpl(message, activatedNow.length > 0 ? activatedNow : undefined)) {
711
+ for await (const ev of self.chatStreamImpl(message, activatedNow.length > 0 ? activatedNow : undefined, signal)) {
717
712
  yield ev;
718
713
  }
719
714
  } catch (err) {
@@ -727,7 +722,8 @@ export class BaseAgent {
727
722
 
728
723
  protected async *chatStreamImpl(
729
724
  message: string,
730
- autoActivated?: string[]
725
+ autoActivated?: string[],
726
+ signal?: AbortSignal
731
727
  ): AsyncGenerator<Record<string, any>> {
732
728
  await this.setState(AgentState.THINKING);
733
729
  this.memory.addMessage('user', message);
@@ -748,12 +744,7 @@ export class BaseAgent {
748
744
  );
749
745
  }
750
746
 
751
- const recentToolOutcomes: boolean[] = [];
752
- let stuckHintInjected = false;
753
- const recentResponseTexts: string[] = [];
754
- let repetitionHintInjected = false;
755
- const recentToolSigs: string[] = [];
756
- let toolLoopHintInjected = false;
747
+ const guard = new LoopGuard();
757
748
 
758
749
  let toolNamesCache: string[] | null = null;
759
750
  let cacheKey: string | null = null;
@@ -779,6 +770,19 @@ export class BaseAgent {
779
770
  let roundCount = 0;
780
771
 
781
772
  while (true) {
773
+ // User interrupt between rounds (Ctrl-C): stop before another LLM call.
774
+ if (signal?.aborted) {
775
+ if (!assistantStored && fullContent.trim()) {
776
+ this.memory.addMessage('assistant', fullContent);
777
+ assistantStored = true;
778
+ } else if (!assistantStored) {
779
+ this.popLastUserMessage();
780
+ }
781
+ await this.setState(AgentState.IDLE);
782
+ yield { type: 'interrupted' };
783
+ yield { type: 'done' };
784
+ return;
785
+ }
782
786
  if (roundCount >= roundLimit) {
783
787
  if (roundLimit >= this._maxToolRoundsHardCap) break;
784
788
  const extendBy = Math.min(15, this._maxToolRoundsHardCap - roundLimit);
@@ -802,7 +806,8 @@ export class BaseAgent {
802
806
  this.name,
803
807
  toolNames.length > 0 ? toolNames : undefined,
804
808
  toolNames.length > 0 ? this.toolRegistry : undefined,
805
- Object.keys(this.getSkillConfigOverrides()).length > 0 ? this.getSkillConfigOverrides() : undefined
809
+ Object.keys(this.getSkillConfigOverrides()).length > 0 ? this.getSkillConfigOverrides() : undefined,
810
+ signal
806
811
  )) {
807
812
  if (event.type === 'content') {
808
813
  fullContent += event.text;
@@ -899,82 +904,12 @@ export class BaseAgent {
899
904
  return;
900
905
  }
901
906
 
902
- // ── Narration-loop detection ──
903
- const normalizedRound = roundContent.trim();
904
- if (normalizedRound && recentResponseTexts.length > 0) {
905
- const highSim = recentResponseTexts.slice(-2).some(prev => textSimilarity(normalizedRound, prev) >= 0.7);
906
- if (highSim && !repetitionHintInjected) {
907
- this.memory.addMessage('system', '[Stop narrating] Your last response is highly similar to your previous one. Stop writing prose. Either: (1) emit ONLY the next tool call, or (2) output the final deliverable.');
908
- repetitionHintInjected = true;
909
- }
910
- }
911
- recentResponseTexts.push(normalizedRound);
912
- if (recentResponseTexts.length > 3) recentResponseTexts.shift();
913
-
914
- // ── Tool-signature loop detection ──
915
- for (const tc of toolCallsReceived) {
916
- const tName = tc.function.name;
917
- if (['task_done', 'list_skills', 'use_skill'].includes(tName)) continue;
918
- const rawArgs = tc.function.arguments;
919
- const tArgs = typeof rawArgs === 'string' ? parseToolArgs(rawArgs) : rawArgs;
920
- const sig = toolCallSignature(tName, tArgs);
921
- if (sig) recentToolSigs.push(sig);
922
- }
923
- if (recentToolSigs.length > SIG_WINDOW) {
924
- recentToolSigs.splice(0, recentToolSigs.length - SIG_WINDOW);
925
- }
926
- if (recentToolSigs.length > 0) {
927
- const counts = new Map<string, number>();
928
- for (const s of recentToolSigs) counts.set(s, (counts.get(s) || 0) + 1);
929
- let topSig = '';
930
- let topCount = 0;
931
- for (const [s, c] of counts) { if (c > topCount) { topSig = s; topCount = c; } }
932
- if (topCount >= SIG_LOOP_HINT && !toolLoopHintInjected) {
933
- this.memory.addMessage('system', `[Tool loop] You have called \`${topSig}\` ${topCount}x in the last ${recentToolSigs.length} tool calls — you are iterating without converging. STOP repeating it.`);
934
- toolLoopHintInjected = true;
935
- }
936
- if (topCount >= SIG_LOOP_HARDSTOP) {
937
- this.memory.addMessage('assistant', `I have repeated \`${topSig}\` ${topCount} times without converging. Stopping.`);
938
- yield { type: 'content', text: `\n\n[stuck] tool \`${topSig}\` repeated ${topCount}x — stopping.` };
939
- await this.setState(AgentState.IDLE);
940
- yield { type: 'done' };
941
- return;
942
- }
943
- }
944
-
945
- // ── Stuck-loop detection ──
946
- for (const r of execResults) {
947
- if (!r || r.toolName === 'task_done') continue;
948
- const failed = !r.success || (typeof r.result === 'string' && looksLikeFailedToolResult(r.result));
949
- recentToolOutcomes.push(!failed);
950
- if (recentToolOutcomes.length > 6) recentToolOutcomes.shift();
951
- }
952
-
953
- if (!stuckHintInjected && recentToolOutcomes.length >= 5 &&
954
- recentToolOutcomes.filter(Boolean).length <= 1) {
955
- this.memory.addMessage('system', '[Recovery hint] Your last several tool calls have mostly failed. Synthesize a partial answer from what worked or ask the user for guidance.');
956
- stuckHintInjected = true;
957
- }
958
-
959
- if (recentToolOutcomes.length >= 8 && recentToolOutcomes.every(x => !x)) {
960
- this.memory.addMessage('assistant', 'Every recent tool call failed. Please give me more context.');
961
- yield { type: 'content', text: '\n\n[stuck] every recent tool call failed — stopping.\n' };
962
- await this.setState(AgentState.IDLE);
963
- yield { type: 'done' };
964
- return;
965
- }
966
-
967
- // ── Search-storm detection ──
968
- const searchStormCount = recentToolSigs.filter(s =>
969
- s.startsWith('web_search:') || ['fetch_page', 'http_get'].includes(s)
970
- ).length;
971
- if (searchStormCount >= 8 && !toolLoopHintInjected) {
972
- this.memory.addMessage('system', `[Search storm] ${searchStormCount} search calls. STOP searching and synthesize.`);
973
- toolLoopHintInjected = true;
974
- }
975
- if (searchStormCount >= 12) {
976
- this.memory.addMessage('assistant', 'Too many search requests. Synthesizing best answer.');
977
- yield { type: 'content', text: `\n\n[stuck] excessive web searching (${searchStormCount} calls) — stopping.\n` };
907
+ // ── Anti-loop guard (narration / tool-signature / stuck / search-storm) ──
908
+ const decision = guard.observe(roundContent, toolCallsReceived, execResults);
909
+ for (const hint of decision.hints) this.memory.addMessage('system', hint);
910
+ if (decision.stop) {
911
+ this.memory.addMessage('assistant', decision.stop.note);
912
+ yield { type: 'content', text: decision.stop.contentLine };
978
913
  await this.setState(AgentState.IDLE);
979
914
  yield { type: 'done' };
980
915
  return;
package/src/core/llm.ts CHANGED
@@ -802,7 +802,7 @@ export class LLMClient {
802
802
  * emitted once complete. Usage comes from the final `stream_options` chunk.
803
803
  */
804
804
  private async *callOpenAIStream(
805
- m: string, messages: Record<string, unknown>[], tools?: string[], temp?: number, maxTok?: number
805
+ m: string, messages: Record<string, unknown>[], tools?: string[], temp?: number, maxTok?: number, signal?: AbortSignal
806
806
  ): AsyncGenerator<StreamEvent> {
807
807
  const apiKey = this.getApiKey(m);
808
808
  const baseUrl = this.getBaseUrl(m);
@@ -814,7 +814,7 @@ export class LLMClient {
814
814
  const defs = tools.map(t => this._toolRegistry.get(t)).filter(Boolean) as any[];
815
815
  if (defs.length) body.tools = defs.map(t => ({ type: "function", function: { name: t.name, description: t.description, parameters: this.paramsToSchema(t.parameters || []) } }));
816
816
  }
817
- const resp = await fetch(baseUrl + "/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer " + apiKey }, body: JSON.stringify(body) });
817
+ const resp = await fetch(baseUrl + "/chat/completions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer " + apiKey }, body: JSON.stringify(body), signal });
818
818
  if (!resp.ok || !resp.body) { const e: any = new Error("API " + resp.status + ": " + ((await resp.text()).slice(0, 200))); e.status_code = resp.status; throw e; }
819
819
 
820
820
  const reader = (resp.body as any).getReader();
@@ -861,7 +861,7 @@ export class LLMClient {
861
861
 
862
862
  async *streamWithTools(
863
863
  messages: Record<string, unknown>[], agentName?: string, tools?: string[],
864
- _toolRegistry?: ToolRegistry, overrides?: Record<string, unknown>
864
+ _toolRegistry?: ToolRegistry, overrides?: Record<string, unknown>, signal?: AbortSignal
865
865
  ): AsyncGenerator<StreamEvent> {
866
866
  this.checkBudget();
867
867
  const ov = overrides || {};
@@ -884,12 +884,14 @@ export class LLMClient {
884
884
  let started = false;
885
885
  let usage: UsageStats = { promptTokens: 0, completionTokens: 0 };
886
886
  try {
887
- for await (const ev of this.callOpenAIStream(model, messages, tools, temperature, maxTokens)) {
887
+ for await (const ev of this.callOpenAIStream(model, messages, tools, temperature, maxTokens, signal)) {
888
888
  if (ev.type === "content" || ev.type === "tool_call") started = true;
889
889
  if (ev.type === "done" && ev.usage) usage = ev.usage;
890
890
  yield ev;
891
891
  }
892
892
  } catch (e: any) {
893
+ // User interrupt (Ctrl-C): stop cleanly — keep whatever streamed, no error, no fallback.
894
+ if (signal?.aborted || e?.name === "AbortError") { yield { type: "done", usage }; return; }
893
895
  if (started) { yield { type: "error", text: String(e?.message || e) }; yield { type: "done", usage }; return; }
894
896
  this.log?.warn("stream_failed_fallback", { model, error: String(e?.message || e) });
895
897
  yield* blockingFallback();
@@ -132,3 +132,28 @@ describe("agent · context window (catalog-aware compaction)", () => {
132
132
  expect((agent as any).shouldAutoCompact()).toBe(false);
133
133
  });
134
134
  });
135
+
136
+ describe("agent · interrupt (Ctrl-C)", () => {
137
+ it("stops between rounds on abort and preserves partial output", async () => {
138
+ const controller = new AbortController();
139
+ // Round 1 streams some content + a tool call; the tool aborts the signal.
140
+ // Round 2 must never run.
141
+ const turns: Turn[] = [
142
+ { content: "部分内容已生成…", toolCalls: [{ name: "spin", args: {} }] },
143
+ { content: "不应出现的第二轮" },
144
+ ];
145
+ const reg = new ToolRegistry();
146
+ reg.register({ name: "spin", description: "spin", handler: async () => { controller.abort(); return "spun"; } });
147
+ const config = { agents: { fog: {} }, llm: {}, memory: { shortTermLimit: 200, dbPath: "/tmp/sky-test" } };
148
+ const agent = new FogAgent(config as any, new MockLLM(turns) as any, new MessageBus(), reg, new SkillRegistry());
149
+
150
+ const evs = await collect(agent.chatStream("go", controller.signal));
151
+ const text = evs.filter((e) => e.type === "content").map((e) => e.text).join("");
152
+
153
+ expect(evs.some((e) => e.type === "interrupted")).toBe(true);
154
+ expect(text).toContain("部分内容已生成"); // partial output kept
155
+ expect(text).not.toContain("第二轮"); // round 2 never streamed
156
+ // partial assistant content is in memory
157
+ expect(agent.memory.getMessages().some((m) => m.role === "assistant" && String(m.content).includes("部分内容"))).toBe(true);
158
+ });
159
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { LoopGuard } from "../src/core/agent/guard";
3
+
4
+ function toolCall(name: string, args: any = {}) {
5
+ return { id: "c" + Math.random(), type: "function", function: { name, arguments: JSON.stringify(args) } } as any;
6
+ }
7
+ const ok = (name: string) => ({ toolName: name, success: true, result: "ok" });
8
+ const fail = (name: string) => ({ toolName: name, success: false, result: "Error: boom" });
9
+
10
+ describe("LoopGuard", () => {
11
+ it("no hints / no stop for a normal round", () => {
12
+ const g = new LoopGuard();
13
+ const d = g.observe("hello", [toolCall("read", { path: "a" })], [ok("read")]);
14
+ expect(d.hints).toEqual([]);
15
+ expect(d.stop).toBeUndefined();
16
+ });
17
+
18
+ it("hard-stops when the same tool signature repeats past the threshold", () => {
19
+ const g = new LoopGuard();
20
+ let last: any;
21
+ for (let i = 0; i < 12; i++) last = g.observe("", [toolCall("spin", { n: 1 })], [ok("spin")]);
22
+ expect(last.stop).toBeDefined();
23
+ expect(last.stop.contentLine).toContain("repeated");
24
+ expect(last.stop.note).toContain("Stopping");
25
+ });
26
+
27
+ it("injects a tool-loop hint once before hard-stopping", () => {
28
+ const g = new LoopGuard();
29
+ const hintRounds: string[][] = [];
30
+ for (let i = 0; i < 12; i++) hintRounds.push(g.observe("", [toolCall("spin", { n: 1 })], [ok("spin")]).hints);
31
+ const allHints = hintRounds.flat();
32
+ expect(allHints.filter((h) => h.includes("[Tool loop]")).length).toBe(1); // once only
33
+ });
34
+
35
+ it("flags a narration loop when responses are near-identical", () => {
36
+ const g = new LoopGuard();
37
+ const text = "我正在分析这个问题并准备给出答案,请稍候。";
38
+ g.observe(text, [], []);
39
+ const d = g.observe(text, [], []); // same content again
40
+ expect(d.hints.some((h) => h.includes("Stop narrating"))).toBe(true);
41
+ });
42
+
43
+ it("injects a recovery hint when most recent tool calls failed", () => {
44
+ const g = new LoopGuard();
45
+ const hints: string[] = [];
46
+ for (let i = 0; i < 5; i++) hints.push(...g.observe("", [toolCall("t" + i)], [fail("t" + i)]).hints);
47
+ expect(hints.some((h) => h.includes("[Recovery hint]"))).toBe(true);
48
+ });
49
+
50
+ it("hard-stops when the last 8 tool calls all failed", () => {
51
+ const g = new LoopGuard();
52
+ let last: any;
53
+ // distinct tool names so the signature-loop guard doesn't fire first
54
+ for (let i = 0; i < 8; i++) last = g.observe("", [toolCall("t" + i, { i })], [fail("t" + i)]);
55
+ expect(last.stop).toBeDefined();
56
+ expect(last.stop.contentLine).toContain("every recent tool call failed");
57
+ });
58
+
59
+ it("hard-stops on a search storm (>=12 distinct search calls)", () => {
60
+ const g = new LoopGuard();
61
+ let last: any;
62
+ // cycle distinct search tools so the signature-loop guard doesn't fire first
63
+ const tools = ["web_search", "fetch_page", "http_get"];
64
+ for (let i = 0; i < 12; i++) last = g.observe("", [toolCall(tools[i % 3], { q: "x" + i })], [ok(tools[i % 3])]);
65
+ expect(last.stop).toBeDefined();
66
+ expect(last.stop.contentLine).toContain("excessive web searching");
67
+ });
68
+
69
+ it("ignores task_done in signature counting (never hard-stops on it)", () => {
70
+ const g = new LoopGuard();
71
+ let last: any;
72
+ for (let i = 0; i < 15; i++) last = g.observe("", [toolCall("task_done", {})], [ok("task_done")]);
73
+ expect(last.stop).toBeUndefined();
74
+ });
75
+ });