groove-dev 0.27.184 → 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 +60 -5
- package/node_modules/@groove-dev/daemon/src/innerchat.js +167 -54
- package/node_modules/@groove-dev/daemon/src/introducer.js +3 -2
- package/node_modules/@groove-dev/daemon/src/process.js +19 -10
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +80 -28
- package/node_modules/@groove-dev/daemon/src/routes/watch.js +42 -0
- package/node_modules/@groove-dev/daemon/src/watcher.js +258 -0
- package/node_modules/@groove-dev/daemon/test/innerchat.test.js +71 -1
- 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/node_modules/@groove-dev/gui/dist/assets/{index-BpOyN6Zf.js → index-DEZwM4Hb.js} +227 -222
- 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/stores/groove.js +30 -16
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +6 -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 +60 -5
- package/packages/daemon/src/innerchat.js +167 -54
- package/packages/daemon/src/introducer.js +3 -2
- package/packages/daemon/src/process.js +19 -10
- package/packages/daemon/src/routes/innerchat.js +80 -28
- package/packages/daemon/src/routes/watch.js +42 -0
- package/packages/daemon/src/watcher.js +258 -0
- package/packages/gui/dist/assets/index-CU8L_r5f.css +1 -0
- package/packages/gui/dist/assets/{index-BpOyN6Zf.js → index-DEZwM4Hb.js} +227 -222
- 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/stores/groove.js +30 -16
- package/packages/gui/src/stores/slices/agents-slice.js +6 -0
- package/packages/gui/src/views/memory.jsx +56 -31
- package/node_modules/@groove-dev/gui/dist/assets/index-DiXB7yry.css +0 -1
- package/packages/gui/dist/assets/index-DiXB7yry.css +0 -1
|
@@ -65,81 +65,127 @@ export class InnerChat {
|
|
|
65
65
|
throw new Error(`${toAgent.name} is already answering another agent — try again shortly`);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
const
|
|
69
|
-
|
|
68
|
+
const t = this._openThread(fromAgent, toAgent, opts.threadId);
|
|
69
|
+
this._checkExchangeCap(t, toAgent);
|
|
70
70
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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`);
|
|
77
99
|
}
|
|
78
100
|
|
|
79
|
-
const
|
|
101
|
+
const t = this._openThread(fromAgent, toAgent, opts.threadId);
|
|
102
|
+
this._checkExchangeCap(t, toAgent);
|
|
80
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) {
|
|
81
123
|
const turn = {
|
|
82
124
|
id: randomUUID().slice(0, 12),
|
|
83
125
|
from: peer(fromAgent),
|
|
84
126
|
to: peer(toAgent),
|
|
85
|
-
text: message
|
|
86
|
-
kind
|
|
127
|
+
text: message,
|
|
128
|
+
kind,
|
|
87
129
|
status: 'sending',
|
|
88
130
|
timestamp: Date.now(),
|
|
89
131
|
};
|
|
90
|
-
|
|
132
|
+
thread.turns.push(turn);
|
|
91
133
|
|
|
92
|
-
const wrapped = this._wrap(
|
|
134
|
+
const wrapped = this._wrap(thread, fromAgent, toAgent, message, kind);
|
|
93
135
|
|
|
94
136
|
let result;
|
|
95
137
|
try {
|
|
96
|
-
result = await deliverInstruction(this.daemon,
|
|
138
|
+
result = await deliverInstruction(this.daemon, toAgent.id, wrapped, { recordFeedback: false });
|
|
97
139
|
} catch (err) {
|
|
98
140
|
turn.status = 'failed';
|
|
99
141
|
turn.error = err.message;
|
|
100
|
-
|
|
101
|
-
this._broadcast(
|
|
142
|
+
thread.status = 'failed';
|
|
143
|
+
this._broadcast(thread, turn);
|
|
102
144
|
throw new Error(`Could not reach ${toAgent.name}: ${err.message}`);
|
|
103
145
|
}
|
|
104
146
|
|
|
105
|
-
// A stopped target gets resumed or rotated,
|
|
106
|
-
// Re-key onto it or the answer will never be matched.
|
|
147
|
+
// A stopped target gets resumed or rotated, minting a new agent id.
|
|
107
148
|
const targetId = result.agentId;
|
|
108
|
-
if (targetId !==
|
|
109
|
-
this._remapParticipant(
|
|
149
|
+
if (targetId !== toAgent.id) {
|
|
150
|
+
this._remapParticipant(thread, toAgent.id, targetId);
|
|
110
151
|
turn.to.id = targetId;
|
|
111
152
|
}
|
|
112
153
|
|
|
113
154
|
turn.status = result.status;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
this._broadcast(
|
|
117
|
-
this.daemon.audit.log('innerchat.ask', { thread: t.id, from: fromAgentId, to: targetId });
|
|
155
|
+
thread.status = 'awaiting_reply';
|
|
156
|
+
thread.updatedAt = Date.now();
|
|
157
|
+
this._broadcast(thread, turn);
|
|
118
158
|
|
|
119
|
-
// A queued
|
|
120
|
-
//
|
|
121
|
-
|
|
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
|
+
}
|
|
122
163
|
|
|
123
|
-
|
|
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
|
+
}
|
|
124
168
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
}
|
|
131
177
|
}
|
|
132
178
|
|
|
133
179
|
_awaitReply(thread, askerId, targetId, { skipResults, timeoutMs }) {
|
|
134
180
|
return new Promise((resolve, reject) => {
|
|
135
181
|
const settle = (fn, value) => {
|
|
136
|
-
|
|
137
|
-
this.awaiting.delete(targetId);
|
|
182
|
+
this._clearAwait(targetId);
|
|
138
183
|
this.blockedOn.delete(askerId);
|
|
139
184
|
fn(value);
|
|
140
185
|
};
|
|
141
186
|
|
|
142
187
|
const record = {
|
|
188
|
+
mode: 'block',
|
|
143
189
|
threadId: thread.id,
|
|
144
190
|
askerId,
|
|
145
191
|
skipResults,
|
|
@@ -150,7 +196,7 @@ export class InnerChat {
|
|
|
150
196
|
thread.updatedAt = Date.now();
|
|
151
197
|
record.reject(new Error(
|
|
152
198
|
`No answer within ${Math.round(timeoutMs / 1000)}s. The agent may still be working — `
|
|
153
|
-
+ 'proceed on your own judgement or
|
|
199
|
+
+ 'proceed on your own judgement, or use a non-blocking send (tell) next time.',
|
|
154
200
|
));
|
|
155
201
|
}, timeoutMs),
|
|
156
202
|
};
|
|
@@ -161,7 +207,7 @@ export class InnerChat {
|
|
|
161
207
|
}
|
|
162
208
|
|
|
163
209
|
/**
|
|
164
|
-
* Watch agent output for the
|
|
210
|
+
* Watch agent output for the reply to an outstanding message.
|
|
165
211
|
* Called from the process manager's output handler.
|
|
166
212
|
*/
|
|
167
213
|
onAgentOutput(agentId, output) {
|
|
@@ -170,7 +216,7 @@ export class InnerChat {
|
|
|
170
216
|
if (output.type !== 'result') return;
|
|
171
217
|
|
|
172
218
|
const thread = this.threads.get(pending.threadId);
|
|
173
|
-
if (!thread) {
|
|
219
|
+
if (!thread) { this._settlePending(agentId, pending, null, new Error('Conversation was lost')); return; }
|
|
174
220
|
|
|
175
221
|
const text = extractText(output.data);
|
|
176
222
|
if (!text) return;
|
|
@@ -182,7 +228,7 @@ export class InnerChat {
|
|
|
182
228
|
}
|
|
183
229
|
|
|
184
230
|
const responder = this.daemon.registry.get(agentId);
|
|
185
|
-
|
|
231
|
+
const answerTurn = {
|
|
186
232
|
id: randomUUID().slice(0, 12),
|
|
187
233
|
from: responder ? peer(responder) : { id: agentId, name: agentId, role: 'agent' },
|
|
188
234
|
to: this._otherParticipant(thread, agentId),
|
|
@@ -190,27 +236,67 @@ export class InnerChat {
|
|
|
190
236
|
kind: 'answer',
|
|
191
237
|
status: 'delivered',
|
|
192
238
|
timestamp: Date.now(),
|
|
193
|
-
}
|
|
239
|
+
};
|
|
240
|
+
thread.turns.push(answerTurn);
|
|
194
241
|
thread.status = 'idle';
|
|
195
242
|
thread.updatedAt = Date.now();
|
|
196
|
-
|
|
197
|
-
this._broadcast(thread, thread.turns.at(-1));
|
|
243
|
+
this._broadcast(thread, answerTurn);
|
|
198
244
|
this.daemon.audit.log('innerchat.answer', { thread: thread.id, from: agentId });
|
|
199
245
|
|
|
200
|
-
pending.
|
|
246
|
+
if (pending.mode === 'async') {
|
|
247
|
+
this._clearAwait(agentId);
|
|
248
|
+
this._forwardAsyncReply(pending, answerTurn, text);
|
|
249
|
+
} else {
|
|
250
|
+
pending.resolve(text);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
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})]`,
|
|
263
|
+
'',
|
|
264
|
+
text,
|
|
265
|
+
'',
|
|
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.',
|
|
268
|
+
].join('\n');
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
await deliverInstruction(this.daemon, sender.id, msg, { recordFeedback: false });
|
|
272
|
+
} catch (err) {
|
|
273
|
+
this.daemon.audit.log('innerchat.forward_failed', { to: sender.id, error: err.message });
|
|
274
|
+
}
|
|
201
275
|
}
|
|
202
276
|
|
|
203
277
|
/**
|
|
204
|
-
* An agent that dies mid-question would otherwise leave
|
|
205
|
-
* until
|
|
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.
|
|
206
280
|
*/
|
|
207
281
|
onAgentGone(agentId, reason = 'stopped') {
|
|
208
282
|
const pending = this.awaiting.get(agentId);
|
|
209
283
|
if (!pending) return;
|
|
284
|
+
if (pending.mode === 'async') { this._clearAwait(agentId); return; }
|
|
210
285
|
const agent = this.daemon.registry.get(agentId);
|
|
211
286
|
pending.reject(new Error(`${agent?.name || agentId} ${reason} before answering`));
|
|
212
287
|
}
|
|
213
288
|
|
|
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
|
+
|
|
214
300
|
// ── Threads ─────────────────────────────────────────────────
|
|
215
301
|
|
|
216
302
|
_createThread(fromAgent, toAgent) {
|
|
@@ -233,10 +319,15 @@ export class InnerChat {
|
|
|
233
319
|
}
|
|
234
320
|
|
|
235
321
|
// The target sees a direct message from the other agent, with enough prior
|
|
236
|
-
// turns to follow a continuing conversation.
|
|
237
|
-
|
|
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]`;
|
|
238
329
|
const prior = thread.turns.slice(0, -1).slice(-CONTEXT_TURNS);
|
|
239
|
-
const lines = [
|
|
330
|
+
const lines = [header, ''];
|
|
240
331
|
|
|
241
332
|
if (prior.length) {
|
|
242
333
|
lines.push('Earlier in this conversation:');
|
|
@@ -245,11 +336,19 @@ export class InnerChat {
|
|
|
245
336
|
}
|
|
246
337
|
|
|
247
338
|
lines.push(message, '');
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
}
|
|
253
352
|
return lines.join('\n');
|
|
254
353
|
}
|
|
255
354
|
|
|
@@ -286,6 +385,20 @@ export class InnerChat {
|
|
|
286
385
|
const pending = this.awaiting.get(agentId);
|
|
287
386
|
return pending ? this.threads.get(pending.threadId) : null;
|
|
288
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
|
+
}
|
|
289
402
|
}
|
|
290
403
|
|
|
291
404
|
function peer(agent) {
|
|
@@ -4,7 +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 } from './innerchat-docs.js';
|
|
7
|
+
import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
|
|
8
8
|
|
|
9
9
|
const GROOVE_SECTION_START = '<!-- GROOVE:START -->';
|
|
10
10
|
const GROOVE_SECTION_END = '<!-- GROOVE:END -->';
|
|
@@ -560,7 +560,8 @@ export class Introducer {
|
|
|
560
560
|
// Written into every AGENTS_REGISTRY.md so the capability survives context
|
|
561
561
|
// compaction — the spawn prompt alone can scroll out of a long session.
|
|
562
562
|
_innerChatSection() {
|
|
563
|
-
|
|
563
|
+
const port = this.daemon.port || 31415;
|
|
564
|
+
return [...innerChatInstructions(port), '', ...watchInstructions(port)];
|
|
564
565
|
}
|
|
565
566
|
|
|
566
567
|
writeRegistryFile(projectDir) {
|
|
@@ -10,7 +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 } from './innerchat-docs.js';
|
|
13
|
+
import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
|
|
14
14
|
|
|
15
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
16
|
const SLIDES_ENGINE_SRC = resolve(__dirname, '../templates/groove-slides.cjs');
|
|
@@ -562,12 +562,17 @@ export class ProcessManager {
|
|
|
562
562
|
const pending = this.consumePendingMessage(agent.id);
|
|
563
563
|
if (pending) {
|
|
564
564
|
const agentData = registry.get(agent.id);
|
|
565
|
-
if
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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;
|
|
571
576
|
}
|
|
572
577
|
}
|
|
573
578
|
|
|
@@ -1165,11 +1170,15 @@ For normal file edits within your scope, proceed without review.
|
|
|
1165
1170
|
// sandboxed providers (Codex) can't reach it.
|
|
1166
1171
|
if (!sandboxedProviders.includes(providerName) && !isOneShotProvider) {
|
|
1167
1172
|
const port = this.daemon.port || 31415;
|
|
1168
|
-
const
|
|
1173
|
+
const capabilities = [
|
|
1174
|
+
...innerChatInstructions(port, agent.name),
|
|
1175
|
+
'',
|
|
1176
|
+
...watchInstructions(port, agent.name),
|
|
1177
|
+
].join('\n') + '\n\n';
|
|
1169
1178
|
if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
|
|
1170
|
-
spawnConfig.prompt += '\n\n' +
|
|
1179
|
+
spawnConfig.prompt += '\n\n' + capabilities.trim();
|
|
1171
1180
|
} else {
|
|
1172
|
-
spawnConfig.prompt =
|
|
1181
|
+
spawnConfig.prompt = capabilities + spawnConfig.prompt;
|
|
1173
1182
|
}
|
|
1174
1183
|
}
|
|
1175
1184
|
|
|
@@ -2,15 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
import { MAX_EXCHANGES } from '../innerchat.js';
|
|
4
4
|
|
|
5
|
-
// Agents know each other by name, not id
|
|
6
|
-
//
|
|
5
|
+
// Agents know each other by name, not id. Exact matches win first so
|
|
6
|
+
// `fullstack-1` never resolves to `fullstack-14`; only if nothing matches
|
|
7
|
+
// exactly do we accept a single unambiguous partial, since agents routinely
|
|
8
|
+
// half-remember a teammate's name. An ambiguous partial resolves to nothing
|
|
9
|
+
// and the caller gets the candidate list instead of a wrong recipient.
|
|
7
10
|
function resolveAgent(daemon, ref) {
|
|
8
11
|
if (!ref || typeof ref !== 'string') return null;
|
|
9
12
|
const all = daemon.registry.getAll();
|
|
10
|
-
|
|
13
|
+
const needle = ref.trim().toLowerCase();
|
|
14
|
+
|
|
15
|
+
const exact = all.find((a) => a.id === ref)
|
|
11
16
|
|| all.find((a) => a.name === ref)
|
|
12
|
-
|| all.find((a) => a.name.toLowerCase() ===
|
|
13
|
-
|
|
17
|
+
|| all.find((a) => a.name.toLowerCase() === needle);
|
|
18
|
+
if (exact) return exact;
|
|
19
|
+
|
|
20
|
+
const partial = all.filter((a) => a.name.toLowerCase().includes(needle)
|
|
21
|
+
|| needle.includes(a.name.toLowerCase()));
|
|
22
|
+
return partial.length === 1 ? partial[0] : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Resolve the from/to pair from a request body, or write the appropriate
|
|
26
|
+
// 400/404 and return null so the caller bails.
|
|
27
|
+
function resolveParties(daemon, req, res) {
|
|
28
|
+
const { from, to, message } = req.body || {};
|
|
29
|
+
|
|
30
|
+
const fromAgent = resolveAgent(daemon, from);
|
|
31
|
+
if (!fromAgent) { res.status(404).json({ error: `Unknown calling agent: ${from}` }); return null; }
|
|
32
|
+
|
|
33
|
+
const toAgent = resolveAgent(daemon, to);
|
|
34
|
+
if (!toAgent) {
|
|
35
|
+
const others = daemon.registry.getAll().filter((a) => a.id !== fromAgent.id);
|
|
36
|
+
const needle = String(to || '').trim().toLowerCase();
|
|
37
|
+
const close = others.filter((a) => a.name.toLowerCase().includes(needle)).map((a) => a.name);
|
|
38
|
+
if (close.length > 1) {
|
|
39
|
+
res.status(404).json({ error: `"${to}" matches more than one agent — use the full name.`, didYouMean: close });
|
|
40
|
+
} else {
|
|
41
|
+
res.status(404).json({ error: `No agent named "${to}".`, availableAgents: others.map((a) => a.name) });
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
47
|
+
res.status(400).json({ error: 'message is required' }); return null;
|
|
48
|
+
}
|
|
49
|
+
return { fromAgent, toAgent, message: message.trim() };
|
|
14
50
|
}
|
|
15
51
|
|
|
16
52
|
export function registerInnerChatRoutes(app, daemon) {
|
|
@@ -18,39 +54,25 @@ export function registerInnerChatRoutes(app, daemon) {
|
|
|
18
54
|
* Ask another agent a question and BLOCK until it answers.
|
|
19
55
|
*
|
|
20
56
|
* This request is held open deliberately — the calling agent is waiting on
|
|
21
|
-
* it, and the response body is the other agent's reply.
|
|
22
|
-
*
|
|
57
|
+
* it, and the response body is the other agent's reply. Best for tight
|
|
58
|
+
* interface negotiation, where the finite exchange budget keeps both sides
|
|
59
|
+
* writing decision-dense messages.
|
|
23
60
|
*/
|
|
24
61
|
app.post('/api/innerchat/ask', async (req, res) => {
|
|
25
62
|
try {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
const fromAgent = resolveAgent(daemon, from);
|
|
29
|
-
if (!fromAgent) return res.status(404).json({ error: `Unknown calling agent: ${from}` });
|
|
30
|
-
|
|
31
|
-
const toAgent = resolveAgent(daemon, to);
|
|
32
|
-
if (!toAgent) {
|
|
33
|
-
const names = daemon.registry.getAll()
|
|
34
|
-
.filter((a) => a.id !== fromAgent.id)
|
|
35
|
-
.map((a) => a.name);
|
|
36
|
-
return res.status(404).json({
|
|
37
|
-
error: `No agent named "${to}".`,
|
|
38
|
-
availableAgents: names,
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
43
|
-
return res.status(400).json({ error: 'message is required' });
|
|
44
|
-
}
|
|
63
|
+
const parties = resolveParties(daemon, req, res);
|
|
64
|
+
if (!parties) return;
|
|
45
65
|
|
|
46
66
|
// Held open until the target answers — see the class doc in innerchat.js.
|
|
47
67
|
req.setTimeout(0);
|
|
48
68
|
res.setTimeout(0);
|
|
49
69
|
|
|
50
|
-
const result = await daemon.innerchat.ask(fromAgent.id, toAgent.id, message
|
|
70
|
+
const result = await daemon.innerchat.ask(parties.fromAgent.id, parties.toAgent.id, parties.message, {
|
|
71
|
+
timeoutMs: req.body?.timeoutMs,
|
|
72
|
+
});
|
|
51
73
|
|
|
52
74
|
res.json({
|
|
53
|
-
from: toAgent.name,
|
|
75
|
+
from: parties.toAgent.name,
|
|
54
76
|
reply: result.reply,
|
|
55
77
|
threadId: result.threadId,
|
|
56
78
|
exchangesUsed: result.exchanges,
|
|
@@ -63,6 +85,36 @@ export function registerInnerChatRoutes(app, daemon) {
|
|
|
63
85
|
}
|
|
64
86
|
});
|
|
65
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Send a message WITHOUT blocking — returns as soon as it's delivered. If the
|
|
90
|
+
* target replies, the reply is routed back to the sender asynchronously
|
|
91
|
+
* (resuming it if its turn ended). Best for handing off to a heads-down agent
|
|
92
|
+
* where waiting out a timeout would waste the turn.
|
|
93
|
+
*/
|
|
94
|
+
app.post('/api/innerchat/tell', async (req, res) => {
|
|
95
|
+
try {
|
|
96
|
+
const parties = resolveParties(daemon, req, res);
|
|
97
|
+
if (!parties) return;
|
|
98
|
+
|
|
99
|
+
const result = await daemon.innerchat.tell(parties.fromAgent.id, parties.toAgent.id, parties.message, {
|
|
100
|
+
threadId: req.body?.threadId,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
res.json({
|
|
104
|
+
ok: true,
|
|
105
|
+
to: parties.toAgent.name,
|
|
106
|
+
delivered: result.delivered,
|
|
107
|
+
threadId: result.threadId,
|
|
108
|
+
exchangesUsed: result.exchanges,
|
|
109
|
+
exchangesRemaining: result.remaining,
|
|
110
|
+
maxExchanges: MAX_EXCHANGES,
|
|
111
|
+
note: `Message delivered. ${parties.toAgent.name}'s reply, if any, will be routed back to you — you can end your turn.`,
|
|
112
|
+
});
|
|
113
|
+
} catch (err) {
|
|
114
|
+
res.status(409).json({ error: err.message });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
66
118
|
app.get('/api/innerchat/threads', (req, res) => {
|
|
67
119
|
const { agentId } = req.query;
|
|
68
120
|
res.json({ threads: daemon.innerchat.getThreads(agentId || null) });
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
|
|
3
|
+
// Agents identify themselves by name; resolve to the live record.
|
|
4
|
+
function resolveAgent(daemon, ref) {
|
|
5
|
+
if (!ref || typeof ref !== 'string') return null;
|
|
6
|
+
const all = daemon.registry.getAll();
|
|
7
|
+
return all.find((a) => a.id === ref)
|
|
8
|
+
|| all.find((a) => a.name === ref)
|
|
9
|
+
|| all.find((a) => a.name.toLowerCase() === ref.trim().toLowerCase())
|
|
10
|
+
|| null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function registerWatchRoutes(app, daemon) {
|
|
14
|
+
// Register a watch and return immediately — the agent's turn ends, and the
|
|
15
|
+
// wake comes later when the watched thing finishes. This does NOT block.
|
|
16
|
+
app.post('/api/watch', (req, res) => {
|
|
17
|
+
try {
|
|
18
|
+
const { agent, command, until, label, timeoutMs, intervalMs } = req.body || {};
|
|
19
|
+
const who = resolveAgent(daemon, agent);
|
|
20
|
+
if (!who) return res.status(404).json({ error: `Unknown agent: ${agent}` });
|
|
21
|
+
|
|
22
|
+
const watch = daemon.watcher.create(who.id, { command, until, label, timeoutMs, intervalMs });
|
|
23
|
+
res.json({
|
|
24
|
+
ok: true,
|
|
25
|
+
watchId: watch.id,
|
|
26
|
+
message: `Watching "${watch.label}". You'll be resumed with the result when it ${watch.mode === 'command' ? 'finishes' : 'condition is met'}. You can end your turn now.`,
|
|
27
|
+
});
|
|
28
|
+
} catch (err) {
|
|
29
|
+
res.status(400).json({ error: err.message });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
app.get('/api/watch', (req, res) => {
|
|
34
|
+
res.json({ watches: daemon.watcher.list(req.query.agentId || null) });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
app.delete('/api/watch/:id', (req, res) => {
|
|
38
|
+
const ok = daemon.watcher.cancel(req.params.id);
|
|
39
|
+
if (!ok) return res.status(404).json({ error: 'Watch not found' });
|
|
40
|
+
res.json({ ok: true });
|
|
41
|
+
});
|
|
42
|
+
}
|