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/=12 +0 -0
- package/=8 +0 -0
- package/README.md +2 -2
- package/dist/cli/main.js +25 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/core/agent/guard.d.ts +45 -0
- package/dist/core/agent/guard.d.ts.map +1 -0
- package/dist/core/agent/guard.js +113 -0
- package/dist/core/agent/guard.js.map +1 -0
- package/dist/core/agent.d.ts +2 -2
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +27 -90
- package/dist/core/agent.js.map +1 -1
- package/dist/core/llm.d.ts +1 -1
- package/dist/core/llm.d.ts.map +1 -1
- package/dist/core/llm.js +9 -4
- package/dist/core/llm.js.map +1 -1
- package/docs/OPTIMIZATION_PLAN.md +4 -4
- package/package.json +68 -68
- package/src/cli/main.ts +17 -1
- package/src/core/agent/guard.ts +134 -0
- package/src/core/agent.ts +27 -92
- package/src/core/llm.ts +6 -4
- package/tests/agent.test.ts +25 -0
- package/tests/guard.test.ts +75 -0
package/package.json
CHANGED
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "skyloom",
|
|
3
|
-
"version": "1.13.
|
|
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
|
|
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
|
-
// ──
|
|
903
|
-
const
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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();
|
package/tests/agent.test.ts
CHANGED
|
@@ -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
|
+
});
|