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
|
@@ -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 };
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
5
5
|
import { resolve, dirname, basename } from 'path';
|
|
6
6
|
import { escapeMd } from './validate.js';
|
|
7
|
+
import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
|
|
7
8
|
|
|
8
9
|
const GROOVE_SECTION_START = '<!-- GROOVE:START -->';
|
|
9
10
|
const GROOVE_SECTION_END = '<!-- GROOVE:END -->';
|
|
@@ -556,6 +557,13 @@ export class Introducer {
|
|
|
556
557
|
return null;
|
|
557
558
|
}
|
|
558
559
|
|
|
560
|
+
// Written into every AGENTS_REGISTRY.md so the capability survives context
|
|
561
|
+
// compaction — the spawn prompt alone can scroll out of a long session.
|
|
562
|
+
_innerChatSection() {
|
|
563
|
+
const port = this.daemon.port || 31415;
|
|
564
|
+
return [...innerChatInstructions(port), '', ...watchInstructions(port)];
|
|
565
|
+
}
|
|
566
|
+
|
|
559
567
|
writeRegistryFile(projectDir) {
|
|
560
568
|
const agents = this.daemon.registry.getAll();
|
|
561
569
|
|
|
@@ -596,6 +604,27 @@ export class Introducer {
|
|
|
596
604
|
lines.push(`| ${escapeMd(a.id)} | ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(a.provider)} | ${agentDir} | ${scope} | ${escapeMd(a.status)} |`);
|
|
597
605
|
}
|
|
598
606
|
|
|
607
|
+
// Agents on other teams are reachable via InnerChat, so they have to be
|
|
608
|
+
// discoverable here — otherwise cross-team consultation means guessing
|
|
609
|
+
// names. Kept to a separate, minimal table so the team's own roster
|
|
610
|
+
// stays the primary listing.
|
|
611
|
+
const others = agents.filter((a) => (a.teamId || '_default') !== teamId);
|
|
612
|
+
if (others.length > 0) {
|
|
613
|
+
lines.push('');
|
|
614
|
+
lines.push(`## Other Teams`);
|
|
615
|
+
lines.push('');
|
|
616
|
+
lines.push(`Reachable via InnerChat (see below). Not in your scope — do not edit their files.`);
|
|
617
|
+
lines.push('');
|
|
618
|
+
lines.push(`| Name | Role | Team | Status |`);
|
|
619
|
+
lines.push(`|------|------|------|--------|`);
|
|
620
|
+
for (const a of others) {
|
|
621
|
+
const otherTeam = this.daemon.teams?.get(a.teamId)?.name || '-';
|
|
622
|
+
lines.push(`| ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(otherTeam)} | ${escapeMd(a.status)} |`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
lines.push('');
|
|
627
|
+
lines.push(...this._innerChatSection());
|
|
599
628
|
lines.push('');
|
|
600
629
|
lines.push(`*Updated: ${new Date().toISOString()}*`);
|
|
601
630
|
|
|
@@ -632,7 +661,8 @@ export class Introducer {
|
|
|
632
661
|
running.length > 0 ? '|------|------|-------|' : '',
|
|
633
662
|
...running.map((a) => `| ${a.name} | ${a.role} | ${a.scope?.join(', ') || '-'} |`),
|
|
634
663
|
'',
|
|
635
|
-
`See AGENTS_REGISTRY.md for full agent state
|
|
664
|
+
`See AGENTS_REGISTRY.md for full agent state, the names of agents on other teams,`,
|
|
665
|
+
`and how to consult them directly (InnerChat).`,
|
|
636
666
|
'',
|
|
637
667
|
`**Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.`,
|
|
638
668
|
'',
|
|
@@ -10,6 +10,7 @@ import { LocalProvider } from './providers/local.js';
|
|
|
10
10
|
import { OllamaProvider } from './providers/ollama.js';
|
|
11
11
|
import { AgentLoop } from './agent-loop.js';
|
|
12
12
|
import { validateAgentConfig } from './validate.js';
|
|
13
|
+
import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
|
|
13
14
|
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const SLIDES_ENGINE_SRC = resolve(__dirname, '../templates/groove-slides.cjs');
|
|
@@ -506,6 +507,13 @@ export class ProcessManager {
|
|
|
506
507
|
|
|
507
508
|
registry.update(agent.id, { status: finalStatus, pid: null });
|
|
508
509
|
|
|
510
|
+
// An agent that dies mid-question leaves its asker blocked on an open HTTP
|
|
511
|
+
// request — fail it now rather than making it wait out the timeout.
|
|
512
|
+
// 'completed' is normal: the answer arrives as a result before this fires.
|
|
513
|
+
if (this.daemon.innerchat && finalStatus !== 'completed') {
|
|
514
|
+
this.daemon.innerchat.onAgentGone(agent.id, finalStatus);
|
|
515
|
+
}
|
|
516
|
+
|
|
509
517
|
if (this.daemon.timeline) {
|
|
510
518
|
const agentData = registry.get(agent.id);
|
|
511
519
|
this.daemon.timeline.recordEvent(finalStatus === 'completed' ? 'complete' : finalStatus === 'crashed' ? 'crash' : 'kill', {
|
|
@@ -554,12 +562,17 @@ export class ProcessManager {
|
|
|
554
562
|
const pending = this.consumePendingMessage(agent.id);
|
|
555
563
|
if (pending) {
|
|
556
564
|
const agentData = registry.get(agent.id);
|
|
557
|
-
if
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
565
|
+
// Deliver the queued message: resume if there's a session, otherwise
|
|
566
|
+
// rotate (fresh session + handoff brief). Previously a missing sessionId
|
|
567
|
+
// meant the already-consumed message was silently dropped — an
|
|
568
|
+
// InnerChat message to a busy agent would vanish and never fire it.
|
|
569
|
+
const deliver = agentData?.sessionId
|
|
570
|
+
? this.resume(agent.id, pending.message)
|
|
571
|
+
: this.daemon.rotator.rotate(agent.id, { additionalPrompt: pending.message });
|
|
572
|
+
deliver.catch((err) => {
|
|
573
|
+
console.error(`[Groove] Delivering queued message to ${agent.name} failed: ${err.message}`);
|
|
574
|
+
});
|
|
575
|
+
return;
|
|
563
576
|
}
|
|
564
577
|
}
|
|
565
578
|
|
|
@@ -1152,6 +1165,23 @@ For normal file edits within your scope, proceed without review.
|
|
|
1152
1165
|
}
|
|
1153
1166
|
}
|
|
1154
1167
|
|
|
1168
|
+
// InnerChat — let the agent consult other agents directly.
|
|
1169
|
+
// Same constraint as PM review: the agent calls back over HTTP, so
|
|
1170
|
+
// sandboxed providers (Codex) can't reach it.
|
|
1171
|
+
if (!sandboxedProviders.includes(providerName) && !isOneShotProvider) {
|
|
1172
|
+
const port = this.daemon.port || 31415;
|
|
1173
|
+
const capabilities = [
|
|
1174
|
+
...innerChatInstructions(port, agent.name),
|
|
1175
|
+
'',
|
|
1176
|
+
...watchInstructions(port, agent.name),
|
|
1177
|
+
].join('\n') + '\n\n';
|
|
1178
|
+
if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
|
|
1179
|
+
spawnConfig.prompt += '\n\n' + capabilities.trim();
|
|
1180
|
+
} else {
|
|
1181
|
+
spawnConfig.prompt = capabilities + spawnConfig.prompt;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1155
1185
|
// Set up log capture (shared between CLI and agent loop paths)
|
|
1156
1186
|
const logDir = resolve(this.daemon.grooveDir, 'logs');
|
|
1157
1187
|
mkdirSync(logDir, { recursive: true });
|