flint-agent 1.14.0
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/.env.example +108 -0
- package/CHANGELOG.md +55 -0
- package/FEATURES.md +298 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/bin/flint.js +47 -0
- package/config/classifier-prompt.md +218 -0
- package/config/models-curated.json +4 -0
- package/config/providers.json +74 -0
- package/package.json +92 -0
- package/patches/ink+6.8.0.patch +78 -0
- package/profiles/desktop.md +65 -0
- package/profiles/generic.md +20 -0
- package/profiles/marketer.md +20 -0
- package/profiles/profiles.json +34 -0
- package/profiles/ux-reviewer.md +25 -0
- package/src/agent/agent.js +1743 -0
- package/src/agent/auto.js +346 -0
- package/src/agent/backoff.js +143 -0
- package/src/agent/compression.js +310 -0
- package/src/agent/content-resolver.js +180 -0
- package/src/agent/flow-controller.js +309 -0
- package/src/agent/intent-manifest.js +231 -0
- package/src/agent/intent-timeout.js +46 -0
- package/src/agent/intent.js +633 -0
- package/src/agent/knowledge.js +114 -0
- package/src/agent/learning.js +180 -0
- package/src/agent/modes.js +187 -0
- package/src/agent/outcome-ask.js +91 -0
- package/src/agent/project-context.js +76 -0
- package/src/agent/prompt-budget.js +117 -0
- package/src/agent/reflection-extractor.js +140 -0
- package/src/agent/steering.js +86 -0
- package/src/agent/supervisor.js +430 -0
- package/src/agent/swap.js +443 -0
- package/src/agent/system-prompt.js +446 -0
- package/src/agent/time-stamp.js +48 -0
- package/src/agent/tool-guard.js +201 -0
- package/src/agent/toolcall-text.js +162 -0
- package/src/agent/usage.js +297 -0
- package/src/agent/vision.js +94 -0
- package/src/agent/watchdog.js +139 -0
- package/src/agent/workspace-changes.js +177 -0
- package/src/api/address.js +14 -0
- package/src/api/client.js +280 -0
- package/src/api/server.js +535 -0
- package/src/api/stream-pipe.js +113 -0
- package/src/app-state.js +39 -0
- package/src/bootstrap.js +501 -0
- package/src/bus/drain-loop.js +497 -0
- package/src/bus/index.js +270 -0
- package/src/bus/plugins.js +65 -0
- package/src/child-idle.js +14 -0
- package/src/cli.js +118 -0
- package/src/commands/commands.js +1297 -0
- package/src/commands/registry.js +132 -0
- package/src/components/App.js +491 -0
- package/src/components/CarefulMenu.js +145 -0
- package/src/components/HistoryWriter.js +86 -0
- package/src/components/LineInput.js +69 -0
- package/src/components/LiveZone.js +294 -0
- package/src/components/OverlayMenu.js +179 -0
- package/src/components/SystemPanel.js +156 -0
- package/src/components/Table.js +54 -0
- package/src/config.js +249 -0
- package/src/free-models.js +230 -0
- package/src/index.js +1111 -0
- package/src/input-handler.js +13 -0
- package/src/input-text.js +123 -0
- package/src/launcher.js +129 -0
- package/src/logging/api-log.js +95 -0
- package/src/logging/chat-log-follower.js +113 -0
- package/src/logging/chat-log.js +15 -0
- package/src/logging/log-collector.js +182 -0
- package/src/logging/logger.js +112 -0
- package/src/logging/tool-log.js +20 -0
- package/src/mcp-client.js +314 -0
- package/src/memory/conversation-digest.js +113 -0
- package/src/memory/extract-facts.js +98 -0
- package/src/memory/facts.js +181 -0
- package/src/memory/inbox.js +63 -0
- package/src/memory/markdown.js +38 -0
- package/src/memory/patterns.js +185 -0
- package/src/memory/project.js +66 -0
- package/src/memory/reflections.js +74 -0
- package/src/memory/retrieval.js +84 -0
- package/src/memory/rules.js +105 -0
- package/src/memory/session-facts.js +125 -0
- package/src/memory/skills.js +191 -0
- package/src/memory/sqlite-store.js +653 -0
- package/src/memory/store.js +208 -0
- package/src/memory/tools.js +196 -0
- package/src/memory/user-model.js +86 -0
- package/src/message-handler.js +775 -0
- package/src/model-check.js +218 -0
- package/src/plugins/loader.js +120 -0
- package/src/plugins/manager.js +88 -0
- package/src/production-env.js +22 -0
- package/src/profiles.js +42 -0
- package/src/providers/adapters/anthropic.js +270 -0
- package/src/providers/adapters/openai.js +120 -0
- package/src/providers/keys-dpapi.js +41 -0
- package/src/providers/keys-fallback.js +31 -0
- package/src/providers/keys.js +132 -0
- package/src/providers/models.js +154 -0
- package/src/providers/registry.js +56 -0
- package/src/providers/state.js +56 -0
- package/src/registry.js +96 -0
- package/src/restart.js +29 -0
- package/src/sandbox/backend.js +130 -0
- package/src/security/api-auth.js +132 -0
- package/src/security/audit.js +98 -0
- package/src/security/child-policy.js +41 -0
- package/src/security/command-guard.js +173 -0
- package/src/security/content-fence.js +250 -0
- package/src/security/content-validator.js +132 -0
- package/src/security/index.js +143 -0
- package/src/security/network-guard.js +126 -0
- package/src/security/pairing.js +180 -0
- package/src/security/path-guard.js +140 -0
- package/src/security/persona-guard.js +67 -0
- package/src/security/policies.js +452 -0
- package/src/security/safety-constants.js +34 -0
- package/src/security/watchdog.js +107 -0
- package/src/sessions.js +130 -0
- package/src/spend.js +97 -0
- package/src/startup-watchdog.js +59 -0
- package/src/stdio/args.js +71 -0
- package/src/stdio/guard.js +59 -0
- package/src/stdio/protocol.js +167 -0
- package/src/stdio/run.js +106 -0
- package/src/stdio/session.js +180 -0
- package/src/store/agent-slice.js +306 -0
- package/src/store/dataset-slice.js +73 -0
- package/src/store/index.js +22 -0
- package/src/store/process-slice.js +135 -0
- package/src/store/session-slice.js +191 -0
- package/src/store/ui-slice.js +119 -0
- package/src/tasks/db.js +184 -0
- package/src/tasks/queries.js +589 -0
- package/src/tools/agent-tools.js +473 -0
- package/src/tools/checkpoint.js +152 -0
- package/src/tools/command-approvals.js +180 -0
- package/src/tools/dataset.js +50 -0
- package/src/tools/filesystem.js +682 -0
- package/src/tools/inbox-tools.js +48 -0
- package/src/tools/mesh.js +135 -0
- package/src/tools/own-env.js +136 -0
- package/src/tools/permissions.js +681 -0
- package/src/tools/plugin-tools.js +123 -0
- package/src/tools/process-tools.js +595 -0
- package/src/tools/registry.js +307 -0
- package/src/tools/swap-tools.js +72 -0
- package/src/tools/system.js +662 -0
- package/src/tools/tasks.js +532 -0
- package/src/tools/tool-search.js +171 -0
- package/src/ui/header.js +140 -0
- package/src/ui/input-cursor.js +23 -0
- package/src/ui/last-line.js +25 -0
- package/src/ui/line-edit.js +135 -0
- package/src/ui/output.js +399 -0
- package/src/ui/paste-tokens.js +131 -0
- package/src/ui/prompt-attention.js +134 -0
- package/src/ui/render-options.js +13 -0
- package/src/ui/replay.js +94 -0
- package/src/ui/splash.js +49 -0
- package/src/ui/status-level.js +36 -0
- package/src/ui/tool-ledger.js +203 -0
- package/src/ui/window-title.js +150 -0
- package/src/update.js +205 -0
- package/system.md +63 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
// Bus drain loop — single consumer that processes all messages from the bus
|
|
2
|
+
// Replaces withLock/drainQueue. All channels push to bus, this loop processes sequentially.
|
|
3
|
+
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { store } from "../store/index.js";
|
|
6
|
+
import { app } from "../app-state.js";
|
|
7
|
+
import { processMessage, handlePendingAction } from "../message-handler.js";
|
|
8
|
+
import { userMsgLine } from "../ui/header.js";
|
|
9
|
+
import * as bus from "./index.js";
|
|
10
|
+
import { createLogger } from "../logging/logger.js";
|
|
11
|
+
import { setSupervisorEnabled } from "../agent/supervisor.js";
|
|
12
|
+
import {
|
|
13
|
+
shouldContinue, isLearningOpportunity, setUserInterrupt, resetFlow,
|
|
14
|
+
FATAL_PROVIDER_REASONS, MAX_CONSECUTIVE_RATE_LIMITS, RATE_LIMIT_WAIT_MS, STOP_REASON_TEXT,
|
|
15
|
+
} from "../agent/flow-controller.js";
|
|
16
|
+
import { extractLearnings } from "../agent/learning.js";
|
|
17
|
+
import { config } from "../config.js";
|
|
18
|
+
import { bulkSetPermission, setUnattended } from "../tools/permissions.js";
|
|
19
|
+
import { getActivePlan, getActiveGoals } from "../tasks/queries.js";
|
|
20
|
+
|
|
21
|
+
const log = createLogger("bus-drain");
|
|
22
|
+
|
|
23
|
+
let _running = false;
|
|
24
|
+
let _drainResolvers = new Map(); // busId → { resolve, reject } for sync API callers
|
|
25
|
+
const _asyncResults = new Map(); // busId → { response, stats, error, ts } for async poll
|
|
26
|
+
const ASYNC_RESULT_TTL = 3600000; // 1 hour
|
|
27
|
+
let _continueTimer = null; // scheduled self-continue timer (clearable on flush)
|
|
28
|
+
|
|
29
|
+
// Autonomous no-progress guard — track tool-call signatures of the last N
|
|
30
|
+
// self-continue iterations. If N in a row are identical, the agent is stuck
|
|
31
|
+
// in a loop (typical pattern: repeatedly calling update_task with the same
|
|
32
|
+
// args because the server rejects it each time, hallucinating success in
|
|
33
|
+
// the response text). Halt autonomous when detected. Observed 2026-04-21:
|
|
34
|
+
// a probe run entered autonomous mode, then re-ran "update task N to
|
|
35
|
+
// in_progress" 50+ times burning $0.10+ before the user noticed.
|
|
36
|
+
const _autonomousSignatureHistory = []; // ring buffer, last N signatures
|
|
37
|
+
const AUTONOMOUS_NO_PROGRESS_THRESHOLD = 3;
|
|
38
|
+
|
|
39
|
+
// How long to wait before handing the run its next turn. Three seconds for a
|
|
40
|
+
// normal continue; a rate-limited turn waits out the provider's window instead.
|
|
41
|
+
const CONTINUE_DELAY_MS = 3000;
|
|
42
|
+
|
|
43
|
+
// Consecutive 429s. It lives here and not in flow-controller because
|
|
44
|
+
// resetFlow() runs at the top of every agent invocation, which would wipe it
|
|
45
|
+
// once per turn and it would never count past one.
|
|
46
|
+
let _rateLimitedInARow = 0;
|
|
47
|
+
|
|
48
|
+
function _toolCallSignature(toolCalls) {
|
|
49
|
+
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return "::none::";
|
|
50
|
+
return toolCalls
|
|
51
|
+
.map((c) => `${c.name}:${JSON.stringify(c.arguments || {}).slice(0, 200)}`)
|
|
52
|
+
.join("|");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// RX-2 fix: track per-message abort controllers so we can cancel a specific
|
|
56
|
+
// in-flight message when its sync HTTP client disconnects. Prevents the
|
|
57
|
+
// "processing slot stuck forever" bug we hit on admin-008 during first live run.
|
|
58
|
+
const _processingControllers = new Map(); // busId → AbortController
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Notify the drain loop that a new message is available.
|
|
62
|
+
* Call this after bus.push().
|
|
63
|
+
* Uses setImmediate to let Ink render the user's message first.
|
|
64
|
+
*/
|
|
65
|
+
export function notify() {
|
|
66
|
+
if (!_running) {
|
|
67
|
+
// setImmediate ensures UI updates (user message line) render before processing starts
|
|
68
|
+
setImmediate(() => _tick());
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Wait for a specific bus message to be processed.
|
|
74
|
+
* Used by API server for sync responses.
|
|
75
|
+
* @param {number} busId
|
|
76
|
+
* @param {number} [timeoutMs=180000]
|
|
77
|
+
* @returns {Promise<{response: string, stats: object}|{error: string}>}
|
|
78
|
+
*/
|
|
79
|
+
export function waitForResult(busId, timeoutMs = 180000) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
_drainResolvers.set(busId, { resolve, reject });
|
|
82
|
+
// Timeout safety
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
if (_drainResolvers.has(busId)) {
|
|
85
|
+
_drainResolvers.delete(busId);
|
|
86
|
+
resolve({ error: "timeout" });
|
|
87
|
+
}
|
|
88
|
+
}, timeoutMs);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* RX-2 fix: Abort a specific in-flight message by bus ID.
|
|
94
|
+
*
|
|
95
|
+
* Called from the HTTP server when the sync client disconnects mid-request.
|
|
96
|
+
* Finds the AbortController for that message and aborts it. The agent loop
|
|
97
|
+
* catches the AbortError in its try/catch/finally and properly calls
|
|
98
|
+
* bus.fail() which releases the processing slot.
|
|
99
|
+
*
|
|
100
|
+
* Also wakes any waiting sync caller with an aborted result so the HTTP
|
|
101
|
+
* response handler can clean up (though the disconnected client will not
|
|
102
|
+
* see it — that's fine).
|
|
103
|
+
*
|
|
104
|
+
* @param {number} busId
|
|
105
|
+
* @returns {boolean} true if an abort was delivered, false if message not in flight
|
|
106
|
+
*/
|
|
107
|
+
export function abortMessage(busId) {
|
|
108
|
+
const controller = _processingControllers.get(busId);
|
|
109
|
+
if (controller) {
|
|
110
|
+
try { controller.abort(new Error("client-disconnect")); } catch {}
|
|
111
|
+
}
|
|
112
|
+
const waiter = _drainResolvers.get(busId);
|
|
113
|
+
if (waiter) {
|
|
114
|
+
_drainResolvers.delete(busId);
|
|
115
|
+
try { waiter.resolve({ error: "aborted" }); } catch {}
|
|
116
|
+
}
|
|
117
|
+
return !!controller;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Abort ALL in-flight messages. Used by `/new` to stop any API traffic before
|
|
122
|
+
* clearing the screen, so the TUI does not render leftover tokens on top of
|
|
123
|
+
* the fresh banner.
|
|
124
|
+
*
|
|
125
|
+
* @returns {number} number of messages aborted
|
|
126
|
+
*/
|
|
127
|
+
export function abortAll() {
|
|
128
|
+
let count = 0;
|
|
129
|
+
for (const [busId, controller] of _processingControllers.entries()) {
|
|
130
|
+
try { controller.abort(new Error("session-reset")); count++; } catch {}
|
|
131
|
+
const waiter = _drainResolvers.get(busId);
|
|
132
|
+
if (waiter) {
|
|
133
|
+
_drainResolvers.delete(busId);
|
|
134
|
+
try { waiter.resolve({ error: "aborted" }); } catch {}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return count;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Process one message from the bus.
|
|
142
|
+
*/
|
|
143
|
+
async function _processOne(msg) {
|
|
144
|
+
store.getState().incrementQueue();
|
|
145
|
+
// Only show message line for non-TUI channels (TUI already displayed it in submit handler)
|
|
146
|
+
// A message that waited above the input is read now: into the history.
|
|
147
|
+
const waited = store.getState().takeQueuedInput?.(msg.id);
|
|
148
|
+
if (waited) userMsgLine(waited.display);
|
|
149
|
+
if (msg.channel !== "user") {
|
|
150
|
+
// No blank line here: the previous turn already ends with one, and two
|
|
151
|
+
// stacked up before every API message (owner, 2026-10-01).
|
|
152
|
+
userMsgLine(msg.content, msg.source);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// User interrupt detection: if a non-self-continue message arrives while
|
|
156
|
+
// there's an active plan with pending tasks, the user is taking control.
|
|
157
|
+
// Set the interrupt flag so shouldContinue() returns stop instead of
|
|
158
|
+
// generating another auto-Continue message. Permanent fix for an issue
|
|
159
|
+
// first seen in v1.0.0 where the user typing "stop" was ignored because the
|
|
160
|
+
// drain loop kept auto-continuing.
|
|
161
|
+
const isSelfContinue = msg.source === "self-continue";
|
|
162
|
+
if (!isSelfContinue && (app.autonomous || msg.channel === "api")) {
|
|
163
|
+
// Cancel any pending auto-continue timer immediately
|
|
164
|
+
if (_continueTimer) {
|
|
165
|
+
clearTimeout(_continueTimer);
|
|
166
|
+
_continueTimer = null;
|
|
167
|
+
log.info("drain: cancelled pending auto-continue (user message arrived)");
|
|
168
|
+
}
|
|
169
|
+
if (msg.channel === "api") {
|
|
170
|
+
// An API message is a new task, not an interruption of one: it starts
|
|
171
|
+
// with fresh run-level state and is allowed to self-continue.
|
|
172
|
+
resetFlow();
|
|
173
|
+
} else if (msg.source !== "auto") {
|
|
174
|
+
// A person typing during an autonomous run takes control. The /auto
|
|
175
|
+
// start message (source "auto") is the run itself, not an interrupt.
|
|
176
|
+
setUserInterrupt(true);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Reset no-progress guard history on any non-self-continue message — the
|
|
180
|
+
// user (or another channel) has taken control, so any prior stuck-loop
|
|
181
|
+
// detection is no longer relevant.
|
|
182
|
+
if (!isSelfContinue) {
|
|
183
|
+
_autonomousSignatureHistory.length = 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let result = null;
|
|
187
|
+
let error = null;
|
|
188
|
+
// Auto-approve tools for API, agent, and autonomous channels
|
|
189
|
+
const isRemote = msg.channel === "api" || msg.channel === "agent" || msg.channel === "autonomous";
|
|
190
|
+
if (isRemote && config.apiAutoApprove) {
|
|
191
|
+
bulkSetPermission("allow");
|
|
192
|
+
}
|
|
193
|
+
// Auto-approve does not cover everything: a hook can still force a prompt for
|
|
194
|
+
// a dangerous command or a secret file, and there is nobody here to answer it.
|
|
195
|
+
setUnattended(isRemote);
|
|
196
|
+
// Auto-enable supervisor for API messages (mid-task detection, verify hints)
|
|
197
|
+
if (isRemote) {
|
|
198
|
+
setSupervisorEnabled(true);
|
|
199
|
+
}
|
|
200
|
+
// Context isolation: clear stale summary for API messages
|
|
201
|
+
// Each API message is an independent task — previous task summary shouldn't leak
|
|
202
|
+
if (msg.channel === "api" && msg.source !== "self-continue") {
|
|
203
|
+
store.getState().setLastSummary("");
|
|
204
|
+
}
|
|
205
|
+
// Eager tool loading: wait for MCP tools before processing
|
|
206
|
+
// Prevents agent from making decisions with incomplete tool set
|
|
207
|
+
if (isRemote && !app.mcpReady) {
|
|
208
|
+
const maxWait = 15000; // 15s max
|
|
209
|
+
const start = Date.now();
|
|
210
|
+
while (!app.mcpReady && Date.now() - start < maxWait) {
|
|
211
|
+
await new Promise(r => setTimeout(r, 500));
|
|
212
|
+
}
|
|
213
|
+
if (app.mcpReady) {
|
|
214
|
+
log.info("mcp-ready: tools loaded before processing API message");
|
|
215
|
+
} else {
|
|
216
|
+
log.warn("mcp-not-ready: proceeding with incomplete tools after %dms", maxWait);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// RX-2 fix: create per-message AbortController, register in map so
|
|
220
|
+
// abortMessage(busId) can cancel this specific in-flight message when
|
|
221
|
+
// its sync client disconnects.
|
|
222
|
+
const msgAbortController = new AbortController();
|
|
223
|
+
_processingControllers.set(msg.id, msgAbortController);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const sender = isRemote ? msg.source : null;
|
|
227
|
+
result = await processMessage(msg.content, sender, { signal: msgAbortController.signal });
|
|
228
|
+
await handlePendingAction();
|
|
229
|
+
bus.complete(msg.id, result?.text?.slice(0, 500) || "ok");
|
|
230
|
+
} catch (err) {
|
|
231
|
+
error = err;
|
|
232
|
+
if (err.name !== "AbortError" && !err.message?.includes("borted")) {
|
|
233
|
+
store.getState().addLine(chalk.red(`Error: ${err.message}`));
|
|
234
|
+
bus.fail(msg.id, err.message);
|
|
235
|
+
} else {
|
|
236
|
+
bus.fail(msg.id, "aborted");
|
|
237
|
+
}
|
|
238
|
+
} finally {
|
|
239
|
+
// RX-2 fix: always clean up the processing controller so stale entries
|
|
240
|
+
// do not leak into the map across multiple messages.
|
|
241
|
+
_processingControllers.delete(msg.id);
|
|
242
|
+
// Restore default permissions after remote message
|
|
243
|
+
if (isRemote && config.apiAutoApprove) {
|
|
244
|
+
bulkSetPermission(null); // clear global override
|
|
245
|
+
}
|
|
246
|
+
setUnattended(false);
|
|
247
|
+
store.getState().decrementQueue();
|
|
248
|
+
}
|
|
249
|
+
// No blank line after the turn: userMsgLine opens the next message with
|
|
250
|
+
// one, and two stacked up between turns (owner, 2026-10-01).
|
|
251
|
+
|
|
252
|
+
// Store result for async poll
|
|
253
|
+
const stopReason = result?.stop_reason || (error ? "error" : "done");
|
|
254
|
+
const resultData = error
|
|
255
|
+
? { error: error.message, ts: Date.now() }
|
|
256
|
+
// toolCalls belong here too. The sync waiter below has always returned them,
|
|
257
|
+
// the async poll path never did, so anything that measures Flint through
|
|
258
|
+
// POST /message + GET /message/:id saw an empty tool list and scored every
|
|
259
|
+
// tool-backed probe as a miss. The readiness harness is exactly that.
|
|
260
|
+
: { response: result?.text || "", stats: result?.stats || {}, stop_reason: stopReason,
|
|
261
|
+
toolCalls: result?.toolCalls || [], ts: Date.now() };
|
|
262
|
+
_asyncResults.set(msg.id, resultData);
|
|
263
|
+
|
|
264
|
+
// Resolve waiting API callers (sync mode)
|
|
265
|
+
const waiter = _drainResolvers.get(msg.id);
|
|
266
|
+
if (waiter) {
|
|
267
|
+
_drainResolvers.delete(msg.id);
|
|
268
|
+
if (error) {
|
|
269
|
+
waiter.resolve({ error: error.message });
|
|
270
|
+
} else {
|
|
271
|
+
waiter.resolve({ response: result?.text || "", stats: result?.stats || {}, stop_reason: stopReason, toolCalls: result?.toolCalls || [] });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Track API goal: remember which goal this API task is working on
|
|
276
|
+
if (msg.channel === "api" && msg.source !== "self-continue") {
|
|
277
|
+
app.apiGoalId = null; // new API task — clear previous
|
|
278
|
+
}
|
|
279
|
+
// After any message: if API-scoped and no goal tracked yet, check if one was just created
|
|
280
|
+
if (app.apiGoalId === null && (msg.channel === "api" || (msg.channel === "autonomous" && msg.source === "self-continue"))) {
|
|
281
|
+
const plan = getActivePlan(store);
|
|
282
|
+
if (plan?.goalId) {
|
|
283
|
+
app.apiGoalId = plan.goalId;
|
|
284
|
+
log.info("api-goal-tracked", { goalId: plan.goalId });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Flow control: decide whether to continue
|
|
289
|
+
if ((app.autonomous || isRemote) && !error && stopReason !== "budget") {
|
|
290
|
+
// A provider refusal is not a flow decision: no prompt changes it, so it is
|
|
291
|
+
// settled before shouldContinue is asked anything. It used to fall straight
|
|
292
|
+
// through, because the only guard here was for "budget" and shouldContinue
|
|
293
|
+
// does not look at stopReason at all. A plan with pending tasks therefore
|
|
294
|
+
// meant "keep going", and a run against a dead account spent three more
|
|
295
|
+
// turns failing identically until the no-progress guard halted it and told
|
|
296
|
+
// the operator the agent was stuck and to use /continue. Wrong cause, and a
|
|
297
|
+
// suggestion that could not work. Measured before the fix: five turns, and
|
|
298
|
+
// the word "provider" appearing nowhere.
|
|
299
|
+
if (FATAL_PROVIDER_REASONS.has(stopReason)) {
|
|
300
|
+
_rateLimitedInARow = 0;
|
|
301
|
+
_autonomousSignatureHistory.length = 0;
|
|
302
|
+
app.apiGoalId = null;
|
|
303
|
+
log.warn("flow: provider refused, stopping", { stopReason });
|
|
304
|
+
store.getState().addLine(chalk.yellow(
|
|
305
|
+
` [flow] Stopped: ${STOP_REASON_TEXT[stopReason] || stopReason} (${stopReason}).`
|
|
306
|
+
));
|
|
307
|
+
if (result?.text) store.getState().addLine(chalk.hex("#555")(` ${result.text.slice(0, 300)}`));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// A 429 is survivable, and the same decision the /auto loop makes:
|
|
312
|
+
// wait out the window the provider asked for and take the turn again. Only
|
|
313
|
+
// a cap that will not lift ends the run.
|
|
314
|
+
let continueDelayMs = CONTINUE_DELAY_MS;
|
|
315
|
+
if (stopReason === "rate-limit") {
|
|
316
|
+
_rateLimitedInARow++;
|
|
317
|
+
if (_rateLimitedInARow > MAX_CONSECUTIVE_RATE_LIMITS) {
|
|
318
|
+
_rateLimitedInARow = 0;
|
|
319
|
+
_autonomousSignatureHistory.length = 0;
|
|
320
|
+
app.apiGoalId = null;
|
|
321
|
+
log.warn("flow: rate limit did not lift, stopping", { stopReason });
|
|
322
|
+
store.getState().addLine(chalk.yellow(
|
|
323
|
+
` [flow] Stopped: ${STOP_REASON_TEXT["rate-limit"]} (rate-limit).`
|
|
324
|
+
));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
continueDelayMs = result?.retryAfter != null ? result.retryAfter * 1000 : RATE_LIMIT_WAIT_MS;
|
|
328
|
+
store.getState().addLine(chalk.yellow(
|
|
329
|
+
` [flow] Rate limited by the provider. Waiting ${Math.round(continueDelayMs / 1000)}s, ` +
|
|
330
|
+
`then retrying (${_rateLimitedInARow} of ${MAX_CONSECUTIVE_RATE_LIMITS}).`
|
|
331
|
+
));
|
|
332
|
+
} else {
|
|
333
|
+
_rateLimitedInARow = 0;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const plan = getActivePlan(store);
|
|
337
|
+
const decision = shouldContinue({
|
|
338
|
+
stopReason,
|
|
339
|
+
plan,
|
|
340
|
+
isApiScoped: !!app.apiGoalId,
|
|
341
|
+
apiGoalId: app.apiGoalId,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
if (decision.action === "stop") {
|
|
345
|
+
if (decision.goalComplete) {
|
|
346
|
+
// Learning: extract patterns from completed task
|
|
347
|
+
const plan = getActivePlan(store);
|
|
348
|
+
if (isLearningOpportunity({ plan, toolCallCount: store.getState().messages?.length || 0 })) {
|
|
349
|
+
try {
|
|
350
|
+
extractLearnings({
|
|
351
|
+
messages: store.getState().messages || [],
|
|
352
|
+
task: msg.content || "",
|
|
353
|
+
plan,
|
|
354
|
+
outcome: "success",
|
|
355
|
+
});
|
|
356
|
+
} catch (e) { log.warn("learning-failed", { error: e.message }); }
|
|
357
|
+
}
|
|
358
|
+
app.apiGoalId = null;
|
|
359
|
+
}
|
|
360
|
+
log.info("flow: stop", { reason: stopReason });
|
|
361
|
+
} else if (decision.action === "continue") {
|
|
362
|
+
// No-progress guard: track signatures of recent autonomous iterations.
|
|
363
|
+
// Reset the history whenever a non-self-continue message is processed
|
|
364
|
+
// (handled at the top of this function via _autonomousSignatureHistory.length = 0
|
|
365
|
+
// when msg.source !== "self-continue").
|
|
366
|
+
// A turn the provider refused is not evidence that the agent is stuck:
|
|
367
|
+
// it did nothing because it was not served. Counting those signatures
|
|
368
|
+
// made the no-progress guard fire first and take the blame for a rate
|
|
369
|
+
// limit.
|
|
370
|
+
if (msg.source === "self-continue" && stopReason !== "rate-limit") {
|
|
371
|
+
const sig = _toolCallSignature(result?.toolCalls);
|
|
372
|
+
_autonomousSignatureHistory.push(sig);
|
|
373
|
+
while (_autonomousSignatureHistory.length > AUTONOMOUS_NO_PROGRESS_THRESHOLD) {
|
|
374
|
+
_autonomousSignatureHistory.shift();
|
|
375
|
+
}
|
|
376
|
+
const stuck = _autonomousSignatureHistory.length === AUTONOMOUS_NO_PROGRESS_THRESHOLD
|
|
377
|
+
&& _autonomousSignatureHistory.every((s) => s === _autonomousSignatureHistory[0]);
|
|
378
|
+
if (stuck) {
|
|
379
|
+
log.warn("autonomous: halt on no-progress", { signature: sig, count: _autonomousSignatureHistory.length });
|
|
380
|
+
store.getState().addLine(chalk.yellow(
|
|
381
|
+
` [flow] Halted: ${AUTONOMOUS_NO_PROGRESS_THRESHOLD} identical autonomous iterations detected (no progress). Use /continue to resume.`
|
|
382
|
+
));
|
|
383
|
+
_autonomousSignatureHistory.length = 0;
|
|
384
|
+
app.apiGoalId = null;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
_continueTimer = setTimeout(() => {
|
|
390
|
+
_continueTimer = null;
|
|
391
|
+
if (app.queueAborted) return; // abort happened while waiting
|
|
392
|
+
bus.push({ channel: "autonomous", content: decision.prompt, priority: bus.PRIORITY?.TASK || 5, source: "self-continue" });
|
|
393
|
+
store.getState().addLine(chalk.hex("#555")(" [flow] Continuing..."));
|
|
394
|
+
notify();
|
|
395
|
+
}, continueDelayMs);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// _scheduleAutoContinue logic moved to flow-controller.js shouldContinue()
|
|
401
|
+
/** Reset autonomous state (call on new autonomous run) */
|
|
402
|
+
export function resetAutonomous() {
|
|
403
|
+
if (_continueTimer) {
|
|
404
|
+
clearTimeout(_continueTimer);
|
|
405
|
+
_continueTimer = null;
|
|
406
|
+
}
|
|
407
|
+
_rateLimitedInARow = 0;
|
|
408
|
+
_autonomousSignatureHistory.length = 0;
|
|
409
|
+
// A new run starts with no interrupt and fresh retry/completion caps.
|
|
410
|
+
resetFlow();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Get async result for a processed message.
|
|
415
|
+
* Returns full response+stats (not truncated like bus DB result).
|
|
416
|
+
* @param {number} busId
|
|
417
|
+
* @returns {{ response?: string, stats?: object, error?: string } | null}
|
|
418
|
+
*/
|
|
419
|
+
export function getAsyncResult(busId) {
|
|
420
|
+
const r = _asyncResults.get(busId);
|
|
421
|
+
if (!r) return null;
|
|
422
|
+
// Cleanup on read (one-time fetch is enough)
|
|
423
|
+
_asyncResults.delete(busId);
|
|
424
|
+
return r;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Periodic cleanup of stale async results */
|
|
428
|
+
setInterval(() => {
|
|
429
|
+
const now = Date.now();
|
|
430
|
+
for (const [id, r] of _asyncResults) {
|
|
431
|
+
if (now - r.ts > ASYNC_RESULT_TTL) _asyncResults.delete(id);
|
|
432
|
+
}
|
|
433
|
+
}, 300000); // every 5 min
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Drain loop tick — process all pending messages, then stop.
|
|
437
|
+
* Re-triggered by notify() when new messages arrive.
|
|
438
|
+
*/
|
|
439
|
+
async function _tick() {
|
|
440
|
+
if (_running) return; // already draining
|
|
441
|
+
_running = true;
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
let msg;
|
|
445
|
+
while ((msg = bus.drain())) {
|
|
446
|
+
if (app.queueAborted) {
|
|
447
|
+
bus.fail(msg.id, "flushed");
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
log.debug("processing", { id: msg.id, channel: msg.channel });
|
|
451
|
+
await _processOne(msg);
|
|
452
|
+
}
|
|
453
|
+
} catch (err) {
|
|
454
|
+
log.error("drain loop error", { error: err.message });
|
|
455
|
+
} finally {
|
|
456
|
+
_running = false;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Check if new messages arrived while we were processing
|
|
460
|
+
const remaining = bus.stats().pending;
|
|
461
|
+
if (remaining > 0) {
|
|
462
|
+
setImmediate(() => _tick().catch(e => log.error("drain re-entry error", { error: e.message })));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Flush: fail all pending messages and abort current processing.
|
|
468
|
+
*/
|
|
469
|
+
export function flush() {
|
|
470
|
+
app.queueAborted = true;
|
|
471
|
+
// Discarded messages leave the queue shown above the input too.
|
|
472
|
+
store.getState().clearQueuedInputs?.();
|
|
473
|
+
// Cancel pending self-continue timer
|
|
474
|
+
if (_continueTimer) {
|
|
475
|
+
clearTimeout(_continueTimer);
|
|
476
|
+
_continueTimer = null;
|
|
477
|
+
}
|
|
478
|
+
// Fail all pending bus messages
|
|
479
|
+
let msg;
|
|
480
|
+
while ((msg = bus.drain())) {
|
|
481
|
+
bus.fail(msg.id, "flushed");
|
|
482
|
+
const waiter = _drainResolvers.get(msg.id);
|
|
483
|
+
if (waiter) {
|
|
484
|
+
_drainResolvers.delete(msg.id);
|
|
485
|
+
waiter.resolve({ error: "flushed" });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// Reset abort flag after current lock chain settles
|
|
489
|
+
setTimeout(() => { app.queueAborted = false; }, 0);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Check if drain loop is currently processing.
|
|
494
|
+
*/
|
|
495
|
+
export function isProcessing() {
|
|
496
|
+
return _running;
|
|
497
|
+
}
|