groove-dev 0.27.183 → 0.27.185
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/.watch-test-flag-1784767927904 +1 -0
- package/.watch-test-flag-1784768055729 +1 -0
- package/.watch-test-flag-1784768066364 +1 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +2 -0
- package/node_modules/@groove-dev/daemon/src/index.js +17 -0
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +96 -0
- package/node_modules/@groove-dev/daemon/src/innerchat.js +265 -96
- package/node_modules/@groove-dev/daemon/src/introducer.js +31 -1
- package/node_modules/@groove-dev/daemon/src/process.js +36 -6
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +109 -13
- package/node_modules/@groove-dev/daemon/src/routes/teams.js +11 -0
- package/node_modules/@groove-dev/daemon/src/routes/watch.js +42 -0
- package/node_modules/@groove-dev/daemon/src/teams.js +28 -0
- package/node_modules/@groove-dev/daemon/src/watcher.js +258 -0
- package/node_modules/@groove-dev/daemon/test/innerchat.test.js +222 -179
- package/node_modules/@groove-dev/daemon/test/teams.test.js +53 -0
- package/node_modules/@groove-dev/daemon/test/watcher.test.js +158 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +1 -0
- package/{packages/gui/dist/assets/index-DPjGBQ5X.js → node_modules/@groove-dev/gui/dist/assets/index-DEZwM4Hb.js} +233 -228
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/app.css +1 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +60 -4
- package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +1 -29
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +1 -15
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
- package/node_modules/@groove-dev/gui/src/stores/groove.js +36 -16
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +6 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +22 -0
- package/node_modules/@groove-dev/gui/src/views/memory.jsx +56 -31
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +2 -0
- package/packages/daemon/src/index.js +17 -0
- package/packages/daemon/src/innerchat-docs.js +96 -0
- package/packages/daemon/src/innerchat.js +265 -96
- package/packages/daemon/src/introducer.js +31 -1
- package/packages/daemon/src/process.js +36 -6
- package/packages/daemon/src/routes/innerchat.js +109 -13
- package/packages/daemon/src/routes/teams.js +11 -0
- package/packages/daemon/src/routes/watch.js +42 -0
- package/packages/daemon/src/teams.js +28 -0
- package/packages/daemon/src/watcher.js +258 -0
- package/packages/gui/dist/assets/index-CU8L_r5f.css +1 -0
- package/{node_modules/@groove-dev/gui/dist/assets/index-DPjGBQ5X.js → packages/gui/dist/assets/index-DEZwM4Hb.js} +233 -228
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/app.css +1 -0
- package/packages/gui/src/components/agents/agent-feed.jsx +60 -4
- package/packages/gui/src/components/agents/agent-panel.jsx +1 -29
- package/packages/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
- package/packages/gui/src/components/fleet/fleet-pane.jsx +1 -15
- package/packages/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
- package/packages/gui/src/stores/groove.js +36 -16
- package/packages/gui/src/stores/slices/agents-slice.js +6 -0
- package/packages/gui/src/stores/slices/teams-slice.js +22 -0
- package/packages/gui/src/views/memory.jsx +56 -31
- package/node_modules/@groove-dev/gui/dist/assets/index-CiOy7wVS.css +0 -1
- package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +0 -145
- package/packages/gui/dist/assets/index-CiOy7wVS.css +0 -1
- package/packages/gui/src/components/agents/innerchat-relay.jsx +0 -145
|
@@ -28,6 +28,7 @@ import { registerFileRoutes, resetEditorRoot } from './routes/files.js';
|
|
|
28
28
|
import { registerNetworkRoutes } from './routes/network.js';
|
|
29
29
|
import { registerScheduleRoutes } from './routes/schedules.js';
|
|
30
30
|
import { registerInnerChatRoutes } from './routes/innerchat.js';
|
|
31
|
+
import { registerWatchRoutes } from './routes/watch.js';
|
|
31
32
|
import { registerAutoAgentRoutes } from './routes/auto-agents.js';
|
|
32
33
|
|
|
33
34
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -178,6 +179,7 @@ export function createApi(app, daemon) {
|
|
|
178
179
|
registerNetworkRoutes(app, daemon);
|
|
179
180
|
registerScheduleRoutes(app, daemon);
|
|
180
181
|
registerInnerChatRoutes(app, daemon);
|
|
182
|
+
registerWatchRoutes(app, daemon);
|
|
181
183
|
registerAutoAgentRoutes(app, daemon);
|
|
182
184
|
|
|
183
185
|
|
|
@@ -47,6 +47,7 @@ import { RepoImporter } from './repo-import.js';
|
|
|
47
47
|
import { ConversationManager } from './conversations.js';
|
|
48
48
|
import { Toys } from './toys.js';
|
|
49
49
|
import { InnerChat } from './innerchat.js';
|
|
50
|
+
import { Watcher } from './watcher.js';
|
|
50
51
|
import { AutoState } from './autostate.js';
|
|
51
52
|
import { Orchestrator } from './orchestrator.js';
|
|
52
53
|
import { TrajectoryCapture, ConsentManager } from '../../../moe-training/client/index.js';
|
|
@@ -162,6 +163,7 @@ export class Daemon {
|
|
|
162
163
|
this.modelLab = new ModelLab(this);
|
|
163
164
|
this.toys = new Toys(this);
|
|
164
165
|
this.innerchat = new InnerChat(this);
|
|
166
|
+
this.watcher = new Watcher(this);
|
|
165
167
|
this.autoState = new AutoState(this.grooveDir);
|
|
166
168
|
this.orchestrator = new Orchestrator(this);
|
|
167
169
|
this.trajectoryCapture = null;
|
|
@@ -623,6 +625,19 @@ export class Daemon {
|
|
|
623
625
|
this.federation.initialize();
|
|
624
626
|
this._startGarbageCollector();
|
|
625
627
|
|
|
628
|
+
// Regenerate the on-disk registry files once on boot. They otherwise
|
|
629
|
+
// only refresh on a registry change, so a daemon upgraded with new
|
|
630
|
+
// agent-facing docs (InnerChat, Watch) would serve stale AGENTS_REGISTRY.md
|
|
631
|
+
// to already-running agents until something happened to change state.
|
|
632
|
+
if (this.registry.getAll().length > 0) {
|
|
633
|
+
try {
|
|
634
|
+
this.introducer.writeRegistryFile(this.projectDir);
|
|
635
|
+
this.introducer.injectGrooveSection(this.projectDir);
|
|
636
|
+
} catch (err) {
|
|
637
|
+
console.error('[startup] Failed to refresh registry files:', err.message);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
626
641
|
// Restore auth token from stored config so subscription polling works after restart
|
|
627
642
|
const storedToken = this.skills.getToken();
|
|
628
643
|
if (storedToken) {
|
|
@@ -847,6 +862,8 @@ export class Daemon {
|
|
|
847
862
|
this.journalist.stop();
|
|
848
863
|
this.rotator.stop();
|
|
849
864
|
this.scheduler.stop();
|
|
865
|
+
this.watcher.stop();
|
|
866
|
+
this.innerchat.stop();
|
|
850
867
|
this.orchestrator.stop();
|
|
851
868
|
this.timeline.stop();
|
|
852
869
|
if (this._gcInterval) clearInterval(this._gcInterval);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
|
|
3
|
+
// The agent-facing InnerChat instructions, in one place.
|
|
4
|
+
//
|
|
5
|
+
// These are injected into a new agent's spawn prompt AND written into every
|
|
6
|
+
// AGENTS_REGISTRY.md. Both are needed: the prompt reaches the agent on turn
|
|
7
|
+
// one, and the file survives context compaction — an agent that has forgotten
|
|
8
|
+
// the prompt can re-read the registry it already knows to consult.
|
|
9
|
+
//
|
|
10
|
+
// No imports on purpose — process.js and introducer.js both pull from here.
|
|
11
|
+
|
|
12
|
+
export function innerChatInstructions(port = 31415, agentName = 'YOUR_NAME') {
|
|
13
|
+
return [
|
|
14
|
+
'## Consulting Other Agents (InnerChat)',
|
|
15
|
+
'',
|
|
16
|
+
'Other GROOVE agents may be working alongside you, including on other teams.',
|
|
17
|
+
'When the user asks you to reach out to, coordinate with, or get input from',
|
|
18
|
+
'another agent, use this — it sends them a message and waits for their reply:',
|
|
19
|
+
'',
|
|
20
|
+
'> **Do NOT use your built-in `SendMessage` / Agent tools to reach a GROOVE agent.**',
|
|
21
|
+
'> Those only address sub-agents you spawned yourself in this session, so they will',
|
|
22
|
+
'> fail with "not reachable" or ask you for an `a…-…` agent ID that does not exist',
|
|
23
|
+
'> here. GROOVE agents are separate processes. The curl below is the only way to',
|
|
24
|
+
'> reach them. Do not invent a fallback (writing a message into a file, etc.) —',
|
|
25
|
+
'> if the curl fails, read the error and report it to the user.',
|
|
26
|
+
'',
|
|
27
|
+
'```bash',
|
|
28
|
+
`curl -s http://localhost:${port}/api/innerchat/ask -X POST -H 'Content-Type: application/json' \\`,
|
|
29
|
+
` -d '{"from":"${agentName}","to":"AGENT_NAME","message":"YOUR_QUESTION"}'`,
|
|
30
|
+
'```',
|
|
31
|
+
'',
|
|
32
|
+
'This BLOCKS until they answer, then returns:',
|
|
33
|
+
'`{"from":"...","reply":"...","exchangesUsed":N,"exchangesRemaining":N}`',
|
|
34
|
+
'',
|
|
35
|
+
'> **Run the curl in the FOREGROUND and let it block.** Do not background it with',
|
|
36
|
+
"> `&`, and never put `&` inside the command — that detaches the curl and throws away",
|
|
37
|
+
"> the reply, so you get no answer and can't confirm delivery. Blocking for a minute or",
|
|
38
|
+
'> two is expected and correct; just wait for it. If you truly cannot wait, use `tell`',
|
|
39
|
+
'> (below) instead — that is the right tool for fire-and-forget, not a backgrounded ask.',
|
|
40
|
+
'',
|
|
41
|
+
'Call it again to continue — prior turns are included automatically, so just say',
|
|
42
|
+
'the next thing. Keep going until you reach a conclusion together, then report',
|
|
43
|
+
'that conclusion to the user. `ask` is best for tight back-and-forth (negotiating',
|
|
44
|
+
'an interface, resolving a blocker) where you need the answer before you proceed.',
|
|
45
|
+
'',
|
|
46
|
+
'If the other agent is heads-down and you do NOT need the answer right now, send it',
|
|
47
|
+
'without blocking instead — this returns immediately, and their reply (if any) is',
|
|
48
|
+
'delivered back to you later, waking you if your turn has ended:',
|
|
49
|
+
'',
|
|
50
|
+
'```bash',
|
|
51
|
+
`curl -s http://localhost:${port}/api/innerchat/tell -X POST -H 'Content-Type: application/json' \\`,
|
|
52
|
+
` -d '{"from":"${agentName}","to":"AGENT_NAME","message":"YOUR_MESSAGE"}'`,
|
|
53
|
+
'```',
|
|
54
|
+
'',
|
|
55
|
+
'**Rules:**',
|
|
56
|
+
'- Only start a conversation when the user asks you to. Never consult other agents on your own initiative.',
|
|
57
|
+
`- \`from\` must be your own agent name${agentName === 'YOUR_NAME' ? ' (it is in your $GROOVE_AGENT_NAME environment variable)' : ''}.`,
|
|
58
|
+
'- Agent names are listed in `AGENTS_REGISTRY.md`, including agents on other teams.',
|
|
59
|
+
'- Use `ask` when you need the reply now; use `tell` to hand off or notify without waiting.',
|
|
60
|
+
'- One message per call. With `ask`, wait for the answer before deciding what to ask next.',
|
|
61
|
+
'- Ground truth lives in files (specs, code, run output), not in chat — use InnerChat to',
|
|
62
|
+
' negotiate and hand off, and verify claims against the actual artifacts.',
|
|
63
|
+
'- Read any error you get back — it tells you what to do (unknown name, cap reached, or they are waiting on you).',
|
|
64
|
+
'- `exchangesRemaining` counts down (shared by ask and tell). At 0 the conversation is over: stop and report.',
|
|
65
|
+
'- If another agent asks YOU something (a blocking ask), answer it directly in your next message — they are blocked until you do.',
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Wake-on-completion. Same delivery/injection story as InnerChat above.
|
|
70
|
+
export function watchInstructions(port = 31415, agentName = 'YOUR_NAME') {
|
|
71
|
+
const nameHint = agentName === 'YOUR_NAME' ? ' ($GROOVE_AGENT_NAME)' : '';
|
|
72
|
+
return [
|
|
73
|
+
'## Getting Notified When Something Finishes (Watch)',
|
|
74
|
+
'',
|
|
75
|
+
'Your process ends when your turn ends, so a test suite or long job you kicked',
|
|
76
|
+
'off has nothing to notify you — telling the user "I\'ll report back when it lands"',
|
|
77
|
+
'does not work on its own; the session is gone. Instead, set a Watch. The daemon',
|
|
78
|
+
'outlives your turn and RESUMES you with the result when it finishes:',
|
|
79
|
+
'',
|
|
80
|
+
'```bash',
|
|
81
|
+
`curl -s http://localhost:${port}/api/watch -X POST -H 'Content-Type: application/json' \\`,
|
|
82
|
+
` -d '{"agent":"${agentName}","label":"test suite","command":"npm test"}'`,
|
|
83
|
+
'```',
|
|
84
|
+
'',
|
|
85
|
+
'This returns immediately with a `watchId`. **End your turn** — you will be woken',
|
|
86
|
+
'with the exit code and output tail when the command finishes.',
|
|
87
|
+
'',
|
|
88
|
+
'- Prefer `command` (let the daemon run it) over launching it yourself — that way',
|
|
89
|
+
' it captures the real exit code and output.',
|
|
90
|
+
'- For something ALREADY running, poll instead with `until` (fires when the check',
|
|
91
|
+
` exits 0): \`-d '{"agent":"${agentName}","label":"server up","until":"curl -sf localhost:3000/health"}'\`.`,
|
|
92
|
+
`- \`agent\` must be your own name${nameHint}.`,
|
|
93
|
+
'- Watches time out (default 30 min). One clear task per watch; do not watch trivial commands you could just run.',
|
|
94
|
+
'- Do not poll or sleep-loop waiting yourself — set the watch and end the turn. That is the whole point.',
|
|
95
|
+
];
|
|
96
|
+
}
|
|
@@ -3,94 +3,212 @@
|
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
import { deliverInstruction } from './deliver.js';
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
6
|
+
// An unanswered ask holds an HTTP request open and blocks the calling agent,
|
|
7
|
+
// so it has to be bounded.
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
+
const MAX_TIMEOUT_MS = 15 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
// Ceiling on autonomous back-and-forth. Two agents that can call each other
|
|
12
|
+
// freely will happily talk until the budget is gone; past this they're told to
|
|
13
|
+
// wrap up and report to the user.
|
|
14
|
+
const MAX_EXCHANGES = 12;
|
|
15
|
+
|
|
8
16
|
const CONTEXT_TURNS = 4;
|
|
9
17
|
const MAX_TURN_CHARS = 1200;
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
|
-
* Agent-to-agent
|
|
20
|
+
* Agent-to-agent consultation.
|
|
13
21
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
22
|
+
* An agent asks another agent a question and BLOCKS until it answers — the
|
|
23
|
+
* daemon holds the caller's HTTP request open, delivers the question, waits
|
|
24
|
+
* for the target's next result, and returns it as the response body. That
|
|
25
|
+
* makes a normal request/response loop available to the calling agent, so two
|
|
26
|
+
* agents can iterate to consensus without a human relaying between them.
|
|
17
27
|
*
|
|
18
28
|
* Delivery goes through deliverInstruction — the same pipe as user chat — so
|
|
19
|
-
* a
|
|
29
|
+
* a question reaches its target whether it's mid-task, idle, or stopped.
|
|
20
30
|
*/
|
|
21
31
|
export class InnerChat {
|
|
22
32
|
constructor(daemon) {
|
|
23
33
|
this.daemon = daemon;
|
|
24
34
|
this.threads = new Map();
|
|
25
|
-
// agentId
|
|
35
|
+
// agentId being asked -> pending record (resolve/reject/timer/threadId)
|
|
26
36
|
this.awaiting = new Map();
|
|
37
|
+
// asker id -> id of the agent it is currently blocked on
|
|
38
|
+
this.blockedOn = new Map();
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
42
|
+
* Ask another agent a question and wait for its answer.
|
|
43
|
+
*
|
|
44
|
+
* Resolves with { reply, threadId, exchanges, remaining }. Rejects when the
|
|
45
|
+
* target can't be reached, the exchange cap is hit, the call would deadlock,
|
|
46
|
+
* or the target doesn't answer inside the timeout.
|
|
32
47
|
*/
|
|
33
|
-
async
|
|
48
|
+
async ask(fromAgentId, toAgentId, message, opts = {}) {
|
|
34
49
|
const fromAgent = this.daemon.registry.get(fromAgentId);
|
|
35
50
|
const toAgent = this.daemon.registry.get(toAgentId);
|
|
36
|
-
if (!fromAgent) throw new Error(`
|
|
51
|
+
if (!fromAgent) throw new Error(`Calling agent ${fromAgentId} not found`);
|
|
37
52
|
if (!toAgent) throw new Error(`Target agent ${toAgentId} not found`);
|
|
38
|
-
if (fromAgentId === toAgentId) throw new Error('
|
|
53
|
+
if (fromAgentId === toAgentId) throw new Error('An agent cannot ask itself');
|
|
39
54
|
if (!message || !message.trim()) throw new Error('message is required');
|
|
40
55
|
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
// Deadlock guard: the target is already blocked waiting on us, so it can
|
|
57
|
+
// never get far enough to read this. Tell the caller to just answer.
|
|
58
|
+
if (this.blockedOn.get(toAgentId) === fromAgentId) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${toAgent.name} is currently waiting for YOUR answer — it cannot reply to a new `
|
|
61
|
+
+ 'question until you respond. Answer its question first; your reply is what unblocks it.',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (this.awaiting.has(toAgentId)) {
|
|
65
|
+
throw new Error(`${toAgent.name} is already answering another agent — try again shortly`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const t = this._openThread(fromAgent, toAgent, opts.threadId);
|
|
69
|
+
this._checkExchangeCap(t, toAgent);
|
|
43
70
|
|
|
44
|
-
const
|
|
71
|
+
const timeoutMs = Math.min(Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
72
|
+
|
|
73
|
+
const { turn, targetId, skipResults } = await this._deliver(t, fromAgent, toAgent, message.trim(), 'ask');
|
|
74
|
+
this.daemon.audit.log('innerchat.ask', { thread: t.id, from: fromAgentId, to: targetId });
|
|
75
|
+
|
|
76
|
+
const reply = await this._awaitReply(t, fromAgentId, targetId, { skipResults, timeoutMs });
|
|
77
|
+
const exchanges = t.turns.filter((x) => x.kind === 'ask' || x.kind === 'tell').length;
|
|
78
|
+
|
|
79
|
+
return { reply, threadId: t.id, exchanges, remaining: MAX_EXCHANGES - exchanges };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Send a message to another agent WITHOUT blocking. Returns as soon as it's
|
|
84
|
+
* delivered. If the target later replies, the reply is routed back to the
|
|
85
|
+
* sender asynchronously — resuming the sender if its turn has ended.
|
|
86
|
+
*
|
|
87
|
+
* This is the fire-and-forget counterpart to ask(): use it to hand off to an
|
|
88
|
+
* agent that's heads-down, where waiting out a timeout would waste the turn.
|
|
89
|
+
*/
|
|
90
|
+
async tell(fromAgentId, toAgentId, message, opts = {}) {
|
|
91
|
+
const fromAgent = this.daemon.registry.get(fromAgentId);
|
|
92
|
+
const toAgent = this.daemon.registry.get(toAgentId);
|
|
93
|
+
if (!fromAgent) throw new Error(`Calling agent ${fromAgentId} not found`);
|
|
94
|
+
if (!toAgent) throw new Error(`Target agent ${toAgentId} not found`);
|
|
95
|
+
if (fromAgentId === toAgentId) throw new Error('An agent cannot message itself');
|
|
96
|
+
if (!message || !message.trim()) throw new Error('message is required');
|
|
97
|
+
if (this.awaiting.has(toAgentId)) {
|
|
98
|
+
throw new Error(`${toAgent.name} already has an unanswered message — wait for its reply before sending another`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const t = this._openThread(fromAgent, toAgent, opts.threadId);
|
|
102
|
+
this._checkExchangeCap(t, toAgent);
|
|
103
|
+
|
|
104
|
+
const { turn, targetId, skipResults } = await this._deliver(t, fromAgent, toAgent, message.trim(), 'tell');
|
|
105
|
+
this.daemon.audit.log('innerchat.tell', { thread: t.id, from: fromAgentId, to: targetId });
|
|
106
|
+
|
|
107
|
+
// Register async capture: the reply (if any) is forwarded, not awaited.
|
|
108
|
+
this.awaiting.set(targetId, {
|
|
109
|
+
mode: 'async',
|
|
110
|
+
threadId: t.id,
|
|
111
|
+
senderId: fromAgentId,
|
|
112
|
+
skipResults,
|
|
113
|
+
timer: setTimeout(() => this._clearAwait(targetId), MAX_TIMEOUT_MS),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const exchanges = t.turns.filter((x) => x.kind === 'ask' || x.kind === 'tell').length;
|
|
117
|
+
return { delivered: true, threadId: t.id, exchanges, remaining: MAX_EXCHANGES - exchanges };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Shared send: push a turn, deliver it, and remap the target id if delivery
|
|
121
|
+
// resumed/rotated the agent. Returns the queued-skip count for reply capture.
|
|
122
|
+
async _deliver(thread, fromAgent, toAgent, message, kind) {
|
|
45
123
|
const turn = {
|
|
46
124
|
id: randomUUID().slice(0, 12),
|
|
47
125
|
from: peer(fromAgent),
|
|
48
126
|
to: peer(toAgent),
|
|
49
|
-
text: message
|
|
50
|
-
kind
|
|
127
|
+
text: message,
|
|
128
|
+
kind,
|
|
51
129
|
status: 'sending',
|
|
52
130
|
timestamp: Date.now(),
|
|
53
131
|
};
|
|
54
|
-
|
|
132
|
+
thread.turns.push(turn);
|
|
55
133
|
|
|
56
|
-
const wrapped = this._wrap(
|
|
134
|
+
const wrapped = this._wrap(thread, fromAgent, toAgent, message, kind);
|
|
57
135
|
|
|
58
136
|
let result;
|
|
59
137
|
try {
|
|
60
|
-
result = await deliverInstruction(this.daemon,
|
|
138
|
+
result = await deliverInstruction(this.daemon, toAgent.id, wrapped, { recordFeedback: false });
|
|
61
139
|
} catch (err) {
|
|
62
140
|
turn.status = 'failed';
|
|
63
141
|
turn.error = err.message;
|
|
64
|
-
|
|
65
|
-
this.
|
|
66
|
-
throw err;
|
|
142
|
+
thread.status = 'failed';
|
|
143
|
+
this._broadcast(thread, turn);
|
|
144
|
+
throw new Error(`Could not reach ${toAgent.name}: ${err.message}`);
|
|
67
145
|
}
|
|
68
146
|
|
|
69
|
-
// A stopped target gets resumed or rotated,
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
this._remapParticipant(
|
|
73
|
-
turn.to.id =
|
|
147
|
+
// A stopped target gets resumed or rotated, minting a new agent id.
|
|
148
|
+
const targetId = result.agentId;
|
|
149
|
+
if (targetId !== toAgent.id) {
|
|
150
|
+
this._remapParticipant(thread, toAgent.id, targetId);
|
|
151
|
+
turn.to.id = targetId;
|
|
74
152
|
}
|
|
75
153
|
|
|
76
154
|
turn.status = result.status;
|
|
77
|
-
|
|
78
|
-
|
|
155
|
+
thread.status = 'awaiting_reply';
|
|
156
|
+
thread.updatedAt = Date.now();
|
|
157
|
+
this._broadcast(thread, turn);
|
|
158
|
+
|
|
159
|
+
// A queued message sits behind the target's current work, so the next
|
|
160
|
+
// result belongs to that prior task, not to us — skip it.
|
|
161
|
+
return { turn, targetId, skipResults: result.status === 'message_queued' ? 1 : 0 };
|
|
162
|
+
}
|
|
79
163
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
164
|
+
_openThread(fromAgent, toAgent, threadId) {
|
|
165
|
+
const thread = threadId ? this.threads.get(threadId) : this._findThread(fromAgent.id, toAgent.id);
|
|
166
|
+
return thread || this._createThread(fromAgent, toAgent);
|
|
167
|
+
}
|
|
84
168
|
|
|
85
|
-
|
|
86
|
-
|
|
169
|
+
_checkExchangeCap(thread, toAgent) {
|
|
170
|
+
const used = thread.turns.filter((x) => x.kind === 'ask' || x.kind === 'tell').length;
|
|
171
|
+
if (used >= MAX_EXCHANGES) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`This conversation has reached its ${MAX_EXCHANGES}-exchange limit. Stop consulting `
|
|
174
|
+
+ `${toAgent.name} and report your conclusion to the user.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
87
178
|
|
|
88
|
-
|
|
179
|
+
_awaitReply(thread, askerId, targetId, { skipResults, timeoutMs }) {
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const settle = (fn, value) => {
|
|
182
|
+
this._clearAwait(targetId);
|
|
183
|
+
this.blockedOn.delete(askerId);
|
|
184
|
+
fn(value);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const record = {
|
|
188
|
+
mode: 'block',
|
|
189
|
+
threadId: thread.id,
|
|
190
|
+
askerId,
|
|
191
|
+
skipResults,
|
|
192
|
+
resolve: (text) => settle(resolve, text),
|
|
193
|
+
reject: (err) => settle(reject, err),
|
|
194
|
+
timer: setTimeout(() => {
|
|
195
|
+
thread.status = 'timeout';
|
|
196
|
+
thread.updatedAt = Date.now();
|
|
197
|
+
record.reject(new Error(
|
|
198
|
+
`No answer within ${Math.round(timeoutMs / 1000)}s. The agent may still be working — `
|
|
199
|
+
+ 'proceed on your own judgement, or use a non-blocking send (tell) next time.',
|
|
200
|
+
));
|
|
201
|
+
}, timeoutMs),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
this.awaiting.set(targetId, record);
|
|
205
|
+
this.blockedOn.set(askerId, targetId);
|
|
206
|
+
});
|
|
89
207
|
}
|
|
90
208
|
|
|
91
209
|
/**
|
|
92
|
-
* Watch agent output for the reply to an outstanding
|
|
93
|
-
*
|
|
210
|
+
* Watch agent output for the reply to an outstanding message.
|
|
211
|
+
* Called from the process manager's output handler.
|
|
94
212
|
*/
|
|
95
213
|
onAgentOutput(agentId, output) {
|
|
96
214
|
const pending = this.awaiting.get(agentId);
|
|
@@ -98,72 +216,88 @@ export class InnerChat {
|
|
|
98
216
|
if (output.type !== 'result') return;
|
|
99
217
|
|
|
100
218
|
const thread = this.threads.get(pending.threadId);
|
|
101
|
-
if (!thread) { this.
|
|
219
|
+
if (!thread) { this._settlePending(agentId, pending, null, new Error('Conversation was lost')); return; }
|
|
102
220
|
|
|
103
|
-
const
|
|
104
|
-
if (!
|
|
221
|
+
const text = extractText(output.data);
|
|
222
|
+
if (!text) return;
|
|
105
223
|
|
|
106
|
-
// Burn off results belonging to work
|
|
107
|
-
// relay was queued behind it.
|
|
224
|
+
// Burn off results belonging to work already underway when we queued.
|
|
108
225
|
if (pending.skipResults > 0) {
|
|
109
226
|
pending.skipResults -= 1;
|
|
110
227
|
return;
|
|
111
228
|
}
|
|
112
229
|
|
|
113
|
-
// Claim the reply before the async forward so a second result arriving
|
|
114
|
-
// mid-flight can't be captured as a duplicate.
|
|
115
|
-
this.awaiting.delete(agentId);
|
|
116
|
-
|
|
117
230
|
const responder = this.daemon.registry.get(agentId);
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
const turn = {
|
|
231
|
+
const answerTurn = {
|
|
121
232
|
id: randomUUID().slice(0, 12),
|
|
122
233
|
from: responder ? peer(responder) : { id: agentId, name: agentId, role: 'agent' },
|
|
123
|
-
to:
|
|
124
|
-
text
|
|
125
|
-
kind: '
|
|
126
|
-
status: '
|
|
234
|
+
to: this._otherParticipant(thread, agentId),
|
|
235
|
+
text,
|
|
236
|
+
kind: 'answer',
|
|
237
|
+
status: 'delivered',
|
|
127
238
|
timestamp: Date.now(),
|
|
128
239
|
};
|
|
129
|
-
thread.turns.push(
|
|
130
|
-
thread.status = '
|
|
240
|
+
thread.turns.push(answerTurn);
|
|
241
|
+
thread.status = 'idle';
|
|
131
242
|
thread.updatedAt = Date.now();
|
|
132
|
-
|
|
133
|
-
this.
|
|
243
|
+
this._broadcast(thread, answerTurn);
|
|
244
|
+
this.daemon.audit.log('innerchat.answer', { thread: thread.id, from: agentId });
|
|
245
|
+
|
|
246
|
+
if (pending.mode === 'async') {
|
|
247
|
+
this._clearAwait(agentId);
|
|
248
|
+
this._forwardAsyncReply(pending, answerTurn, text);
|
|
249
|
+
} else {
|
|
250
|
+
pending.resolve(text);
|
|
251
|
+
}
|
|
134
252
|
}
|
|
135
253
|
|
|
136
|
-
async
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
254
|
+
// Deliver an async (tell) reply back to the original sender, resuming it if
|
|
255
|
+
// its turn has ended. The sender is not blocked, so this just wakes them.
|
|
256
|
+
async _forwardAsyncReply(pending, answerTurn, text) {
|
|
257
|
+
const sender = this.daemon.registry.get(pending.senderId)
|
|
258
|
+
|| this.daemon.registry.getAll().find((a) => a.name === answerTurn.to.name);
|
|
259
|
+
if (!sender) return; // sender is gone — nothing to route back to
|
|
260
|
+
|
|
261
|
+
const msg = [
|
|
262
|
+
`[InnerChat reply from ${answerTurn.from.name} (${answerTurn.from.role})]`,
|
|
140
263
|
'',
|
|
141
|
-
|
|
264
|
+
text,
|
|
142
265
|
'',
|
|
143
|
-
|
|
144
|
-
|
|
266
|
+
'This is a reply to a message you sent earlier (you were not blocking on it). '
|
|
267
|
+
+ 'Fold it into your work, or reply again to continue.',
|
|
145
268
|
].join('\n');
|
|
146
269
|
|
|
147
270
|
try {
|
|
148
|
-
|
|
149
|
-
if (result.agentId !== askerId) {
|
|
150
|
-
this._remapParticipant(thread, askerId, result.agentId);
|
|
151
|
-
turn.to.id = result.agentId;
|
|
152
|
-
}
|
|
153
|
-
turn.status = result.status;
|
|
154
|
-
thread.status = 'idle';
|
|
271
|
+
await deliverInstruction(this.daemon, sender.id, msg, { recordFeedback: false });
|
|
155
272
|
} catch (err) {
|
|
156
|
-
|
|
157
|
-
turn.error = err.message;
|
|
158
|
-
thread.status = 'failed';
|
|
273
|
+
this.daemon.audit.log('innerchat.forward_failed', { to: sender.id, error: err.message });
|
|
159
274
|
}
|
|
275
|
+
}
|
|
160
276
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
277
|
+
/**
|
|
278
|
+
* An agent that dies mid-question would otherwise leave a blocking asker
|
|
279
|
+
* stuck until timeout. Async senders aren't waiting, so just clear those.
|
|
280
|
+
*/
|
|
281
|
+
onAgentGone(agentId, reason = 'stopped') {
|
|
282
|
+
const pending = this.awaiting.get(agentId);
|
|
283
|
+
if (!pending) return;
|
|
284
|
+
if (pending.mode === 'async') { this._clearAwait(agentId); return; }
|
|
285
|
+
const agent = this.daemon.registry.get(agentId);
|
|
286
|
+
pending.reject(new Error(`${agent?.name || agentId} ${reason} before answering`));
|
|
164
287
|
}
|
|
165
288
|
|
|
166
|
-
|
|
289
|
+
_clearAwait(targetId) {
|
|
290
|
+
const pending = this.awaiting.get(targetId);
|
|
291
|
+
if (pending?.timer) clearTimeout(pending.timer);
|
|
292
|
+
this.awaiting.delete(targetId);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
_settlePending(agentId, pending, value, err) {
|
|
296
|
+
if (pending.mode === 'async') { this._clearAwait(agentId); return; }
|
|
297
|
+
if (err) pending.reject(err); else pending.resolve(value);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Threads ─────────────────────────────────────────────────
|
|
167
301
|
|
|
168
302
|
_createThread(fromAgent, toAgent) {
|
|
169
303
|
const thread = {
|
|
@@ -178,25 +312,43 @@ export class InnerChat {
|
|
|
178
312
|
return thread;
|
|
179
313
|
}
|
|
180
314
|
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
315
|
+
// Consecutive asks between the same pair continue one conversation, so the
|
|
316
|
+
// exchange cap actually bounds a back-and-forth rather than resetting.
|
|
317
|
+
_findThread(a, b) {
|
|
318
|
+
return this.getThreads(a).find((t) => t.participants.some((p) => p.id === b)) || null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// The target sees a direct message from the other agent, with enough prior
|
|
322
|
+
// turns to follow a continuing conversation. The closing instruction differs
|
|
323
|
+
// by kind: an ask blocks the sender (answer now), a tell does not (answer
|
|
324
|
+
// when you reach a good stopping point).
|
|
325
|
+
_wrap(thread, fromAgent, toAgent, message, kind = 'ask') {
|
|
326
|
+
const header = kind === 'tell'
|
|
327
|
+
? `[InnerChat — ${fromAgent.name} (${fromAgent.role}) sent you a message]`
|
|
328
|
+
: `[InnerChat — ${fromAgent.name} (${fromAgent.role}) is asking you a question]`;
|
|
184
329
|
const prior = thread.turns.slice(0, -1).slice(-CONTEXT_TURNS);
|
|
185
|
-
const lines = [
|
|
330
|
+
const lines = [header, ''];
|
|
186
331
|
|
|
187
332
|
if (prior.length) {
|
|
188
333
|
lines.push('Earlier in this conversation:');
|
|
189
|
-
for (const t of prior) {
|
|
190
|
-
lines.push(` ${t.from.name}: ${truncate(t.text, MAX_TURN_CHARS)}`);
|
|
191
|
-
}
|
|
334
|
+
for (const t of prior) lines.push(` ${t.from.name}: ${truncate(t.text, MAX_TURN_CHARS)}`);
|
|
192
335
|
lines.push('');
|
|
193
336
|
}
|
|
194
337
|
|
|
195
338
|
lines.push(message, '');
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
339
|
+
if (kind === 'tell') {
|
|
340
|
+
lines.push(
|
|
341
|
+
`${fromAgent.name} is NOT blocked on this — finish what you're doing first if you're `
|
|
342
|
+
+ 'mid-task. When you reach a good stopping point, answer directly in a message; your '
|
|
343
|
+
+ `reply is relayed back to ${fromAgent.name}. If no reply is needed, just carry on.`,
|
|
344
|
+
);
|
|
345
|
+
} else {
|
|
346
|
+
lines.push(
|
|
347
|
+
`${fromAgent.name} is BLOCKED waiting on your answer — it cannot continue until you `
|
|
348
|
+
+ 'respond. Answer directly and concisely in your next message; that message is sent '
|
|
349
|
+
+ 'back to it verbatim. Do not address the user, and do not start unrelated work first.',
|
|
350
|
+
);
|
|
351
|
+
}
|
|
200
352
|
return lines.join('\n');
|
|
201
353
|
}
|
|
202
354
|
|
|
@@ -205,10 +357,7 @@ export class InnerChat {
|
|
|
205
357
|
}
|
|
206
358
|
|
|
207
359
|
_remapParticipant(thread, oldId, newId) {
|
|
208
|
-
for (const p of thread.participants)
|
|
209
|
-
if (p.id === oldId) p.id = newId;
|
|
210
|
-
}
|
|
211
|
-
// Any relay still awaiting a reply from the old id must follow it forward.
|
|
360
|
+
for (const p of thread.participants) if (p.id === oldId) p.id = newId;
|
|
212
361
|
const pending = this.awaiting.get(oldId);
|
|
213
362
|
if (pending) {
|
|
214
363
|
this.awaiting.delete(oldId);
|
|
@@ -216,6 +365,10 @@ export class InnerChat {
|
|
|
216
365
|
}
|
|
217
366
|
}
|
|
218
367
|
|
|
368
|
+
_broadcast(thread, turn) {
|
|
369
|
+
this.daemon.broadcast({ type: 'innerchat:turn', data: { thread, turn } });
|
|
370
|
+
}
|
|
371
|
+
|
|
219
372
|
// ── Queries ─────────────────────────────────────────────────
|
|
220
373
|
|
|
221
374
|
getThreads(agentId = null) {
|
|
@@ -232,6 +385,20 @@ export class InnerChat {
|
|
|
232
385
|
const pending = this.awaiting.get(agentId);
|
|
233
386
|
return pending ? this.threads.get(pending.threadId) : null;
|
|
234
387
|
}
|
|
388
|
+
|
|
389
|
+
// Clear all outstanding timers so a shutdown (or a test) doesn't hang on
|
|
390
|
+
// pending ask/tell timeouts. Blocking asks are rejected so their HTTP
|
|
391
|
+
// requests don't hang either.
|
|
392
|
+
stop() {
|
|
393
|
+
for (const [targetId, pending] of this.awaiting) {
|
|
394
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
395
|
+
if (pending.mode === 'block') {
|
|
396
|
+
try { pending.reject(new Error('Daemon shutting down')); } catch { /* already settled */ }
|
|
397
|
+
}
|
|
398
|
+
this.awaiting.delete(targetId);
|
|
399
|
+
}
|
|
400
|
+
this.blockedOn.clear();
|
|
401
|
+
}
|
|
235
402
|
}
|
|
236
403
|
|
|
237
404
|
function peer(agent) {
|
|
@@ -249,3 +416,5 @@ function extractText(data) {
|
|
|
249
416
|
}
|
|
250
417
|
return '';
|
|
251
418
|
}
|
|
419
|
+
|
|
420
|
+
export { MAX_EXCHANGES, DEFAULT_TIMEOUT_MS };
|