bloby-bot 0.47.6 → 0.47.8
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/package.json +1 -1
- package/supervisor/harnesses/pi/async-queue.ts +10 -0
- package/supervisor/harnesses/pi/index.ts +21 -0
- package/supervisor/harnesses/pi/providers/stream-google.ts +9 -2
- package/supervisor/harnesses/pi/providers/types.ts +5 -2
- package/supervisor/harnesses/pi/session.ts +40 -8
package/package.json
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
export interface AsyncQueue<T> extends AsyncIterable<T> {
|
|
10
10
|
push(item: T): void;
|
|
11
11
|
end(): void;
|
|
12
|
+
/**
|
|
13
|
+
* Non-blocking drain: return every item currently buffered without waiting
|
|
14
|
+
* for a new one. Used by the session to fold mid-turn user messages into
|
|
15
|
+
* the model's history without breaking the outer `for await` consumer.
|
|
16
|
+
*/
|
|
17
|
+
drainPending(): T[];
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
export function createAsyncQueue<T>(): AsyncQueue<T> {
|
|
@@ -30,6 +36,10 @@ export function createAsyncQueue<T>(): AsyncQueue<T> {
|
|
|
30
36
|
done = true;
|
|
31
37
|
if (resolve) resolve({ value: undefined as any, done: true });
|
|
32
38
|
},
|
|
39
|
+
drainPending() {
|
|
40
|
+
if (pending.length === 0) return [];
|
|
41
|
+
return pending.splice(0, pending.length);
|
|
42
|
+
},
|
|
33
43
|
[Symbol.asyncIterator]() {
|
|
34
44
|
return {
|
|
35
45
|
next(): Promise<IteratorResult<T>> {
|
|
@@ -82,6 +82,26 @@ function formatConversationHistory(messages: RecentMessage[]): string {
|
|
|
82
82
|
return messages.map((m) => `${m.role}: ${m.content}`).join('\n\n');
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Live-conversation pacing hint. The Claude Agent SDK trains its model to do
|
|
87
|
+
* this natively; non-Anthropic models (Gemini especially) tend to go silent
|
|
88
|
+
* during tool loops and never report progress. This nudge makes the
|
|
89
|
+
* conversation feel alive — quick acknowledgement before long tasks, short
|
|
90
|
+
* status notes between tool calls, and inline answers if the user types
|
|
91
|
+
* something while the agent is mid-task.
|
|
92
|
+
*/
|
|
93
|
+
const LIVE_CONVERSATION_HINT = `
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
# Live-conversation pacing
|
|
97
|
+
|
|
98
|
+
You are running in a streaming chat where the user can keep typing while you work. Make the conversation feel alive:
|
|
99
|
+
|
|
100
|
+
- Before kicking off a multi-step task, say one short line acknowledging it ("On it, looking at the widget now.").
|
|
101
|
+
- Between tool calls on long tasks, drop a brief progress note ("Found the file, checking the layout next.") so the user knows you're still working.
|
|
102
|
+
- If a new user message arrives while you're mid-task, you'll see it as a fresh user-role message in the conversation history. Answer it briefly inline, mention you're still working on the main task, then continue.
|
|
103
|
+
- Final answers should be concise and concrete.`;
|
|
104
|
+
|
|
85
105
|
async function buildSystemPrompt(
|
|
86
106
|
names?: { botName: string; humanName: string },
|
|
87
107
|
recentMessages?: RecentMessage[],
|
|
@@ -89,6 +109,7 @@ async function buildSystemPrompt(
|
|
|
89
109
|
const memoryFiles = readMemoryFiles();
|
|
90
110
|
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
|
|
91
111
|
let systemPrompt = basePrompt;
|
|
112
|
+
systemPrompt += LIVE_CONVERSATION_HINT;
|
|
92
113
|
systemPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
|
|
93
114
|
|
|
94
115
|
try {
|
|
@@ -100,8 +100,12 @@ function toGeminiParts(content: PiContentBlock[]): any[] {
|
|
|
100
100
|
} else if (b.type === 'image') {
|
|
101
101
|
parts.push({ inlineData: { mimeType: b.mediaType, data: b.data } });
|
|
102
102
|
} else if (b.type === 'tool_use') {
|
|
103
|
-
// Assistant turn: the model asked to invoke a tool.
|
|
104
|
-
|
|
103
|
+
// Assistant turn: the model asked to invoke a tool. Thinking-capable
|
|
104
|
+
// Gemini 3.x rejects (HTTP 400) any echoed functionCall whose
|
|
105
|
+
// thoughtSignature is missing, so we forward it verbatim when present.
|
|
106
|
+
const part: any = { functionCall: { name: b.name, args: b.input || {} } };
|
|
107
|
+
if (b.thoughtSignature) part.thoughtSignature = b.thoughtSignature;
|
|
108
|
+
parts.push(part);
|
|
105
109
|
} else if (b.type === 'tool_result') {
|
|
106
110
|
// Function responses can be strings, objects, or even error markers.
|
|
107
111
|
// Wrap text in `{ output: ... }` (Gemini's docs use a flexible
|
|
@@ -262,6 +266,9 @@ export async function* streamGoogle(req: PiStreamRequest): AsyncIterable<PiStrea
|
|
|
262
266
|
id,
|
|
263
267
|
name: part.functionCall.name,
|
|
264
268
|
input: part.functionCall.args || {},
|
|
269
|
+
// Thinking-capable models attach a signature that we must echo
|
|
270
|
+
// back unchanged on the next turn. Optional on non-thinking models.
|
|
271
|
+
thoughtSignature: typeof part.thoughtSignature === 'string' ? part.thoughtSignature : undefined,
|
|
265
272
|
};
|
|
266
273
|
continue;
|
|
267
274
|
}
|
|
@@ -17,7 +17,10 @@ export type PiRole = 'user' | 'assistant' | 'tool';
|
|
|
17
17
|
export type PiContentBlock =
|
|
18
18
|
| { type: 'text'; text: string }
|
|
19
19
|
| { type: 'image'; mediaType: string; data: string } // base64
|
|
20
|
-
|
|
20
|
+
// `thoughtSignature` is a Gemini 3.x thinking-model field. Pi-flavored
|
|
21
|
+
// providers that emit reasoning attach it to function-call parts; the API
|
|
22
|
+
// rejects the next turn with HTTP 400 if we don't echo it back verbatim.
|
|
23
|
+
| { type: 'tool_use'; id: string; name: string; input: any; thoughtSignature?: string }
|
|
21
24
|
| { type: 'tool_result'; toolUseId: string; content: string; isError?: boolean };
|
|
22
25
|
|
|
23
26
|
export interface PiMessage {
|
|
@@ -50,7 +53,7 @@ export type PiStopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'error' | 'a
|
|
|
50
53
|
export type PiStreamEvent =
|
|
51
54
|
| { type: 'text_delta'; delta: string }
|
|
52
55
|
| { type: 'text_end'; text: string }
|
|
53
|
-
| { type: 'tool_use'; id: string; name: string; input: any }
|
|
56
|
+
| { type: 'tool_use'; id: string; name: string; input: any; thoughtSignature?: string }
|
|
54
57
|
| { type: 'done'; stopReason: PiStopReason; usage?: PiUsage }
|
|
55
58
|
| { type: 'error'; error: string };
|
|
56
59
|
|
|
@@ -68,7 +68,7 @@ export function createPiSession(init: PiSessionInit): PiSession {
|
|
|
68
68
|
/** One stream round — collect the assistant blocks the model emits this pass. */
|
|
69
69
|
interface RoundResult {
|
|
70
70
|
text: string;
|
|
71
|
-
toolUses: { id: string; name: string; input: any }[];
|
|
71
|
+
toolUses: { id: string; name: string; input: any; thoughtSignature?: string }[];
|
|
72
72
|
errored: boolean;
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -100,7 +100,12 @@ export function createPiSession(init: PiSessionInit): PiSession {
|
|
|
100
100
|
result.text = evt.text;
|
|
101
101
|
break;
|
|
102
102
|
case 'tool_use':
|
|
103
|
-
result.toolUses.push({
|
|
103
|
+
result.toolUses.push({
|
|
104
|
+
id: evt.id,
|
|
105
|
+
name: evt.name,
|
|
106
|
+
input: evt.input,
|
|
107
|
+
thoughtSignature: evt.thoughtSignature,
|
|
108
|
+
});
|
|
104
109
|
init.onEvent({ type: 'tool_use', id: evt.id, name: evt.name, input: evt.input });
|
|
105
110
|
break;
|
|
106
111
|
case 'error':
|
|
@@ -136,14 +141,17 @@ export function createPiSession(init: PiSessionInit): PiSession {
|
|
|
136
141
|
}
|
|
137
142
|
}
|
|
138
143
|
|
|
139
|
-
async function runOneTurn(
|
|
144
|
+
async function runOneTurn(input: AsyncQueue<PiMessage>, firstUserMsg: PiMessage): Promise<void> {
|
|
140
145
|
if (init.abortController.signal.aborted) return;
|
|
141
|
-
messages.
|
|
146
|
+
// Stack any messages that arrived in the same millisecond into one turn.
|
|
147
|
+
messages.push(firstUserMsg);
|
|
148
|
+
for (const extra of input.drainPending()) messages.push(extra);
|
|
142
149
|
init.onEvent({ type: 'turn_started' });
|
|
143
150
|
|
|
144
151
|
let accumulatedText = '';
|
|
145
152
|
const usedTools = new Set<string>();
|
|
146
153
|
let turnErrored = false;
|
|
154
|
+
let pendingInterleave = false;
|
|
147
155
|
|
|
148
156
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
149
157
|
if (init.abortController.signal.aborted) break;
|
|
@@ -157,14 +165,21 @@ export function createPiSession(init: PiSessionInit): PiSession {
|
|
|
157
165
|
assistantContent.push({ type: 'text', text });
|
|
158
166
|
}
|
|
159
167
|
for (const tu of toolUses) {
|
|
160
|
-
assistantContent.push({
|
|
168
|
+
assistantContent.push({
|
|
169
|
+
type: 'tool_use',
|
|
170
|
+
id: tu.id,
|
|
171
|
+
name: tu.name,
|
|
172
|
+
input: tu.input,
|
|
173
|
+
// Forward Gemini's thoughtSignature unchanged so the next turn's
|
|
174
|
+
// request echoes it back; without it the API rejects with 400.
|
|
175
|
+
thoughtSignature: tu.thoughtSignature,
|
|
176
|
+
});
|
|
161
177
|
}
|
|
162
178
|
if (assistantContent.length > 0) {
|
|
163
179
|
messages.push({ role: 'assistant', content: assistantContent });
|
|
164
180
|
}
|
|
165
181
|
|
|
166
182
|
if (errored) { turnErrored = true; break; }
|
|
167
|
-
if (toolUses.length === 0) break; // model finished — exit loop
|
|
168
183
|
|
|
169
184
|
// Run every tool the model asked for this round, then feed the results
|
|
170
185
|
// back as a single user message Gemini accepts as a batch.
|
|
@@ -185,7 +200,24 @@ export function createPiSession(init: PiSessionInit): PiSession {
|
|
|
185
200
|
if (toolResultBlocks.length > 0) {
|
|
186
201
|
messages.push({ role: 'user', content: toolResultBlocks });
|
|
187
202
|
}
|
|
188
|
-
|
|
203
|
+
|
|
204
|
+
// Fold any user messages that arrived during this round into history so
|
|
205
|
+
// the next stream pass sees them. This is what makes the conversation
|
|
206
|
+
// feel alive: while the agent is grinding on a long task, a question
|
|
207
|
+
// typed mid-stream lands in the very next request as a user-role part,
|
|
208
|
+
// and the model can answer it inline before continuing.
|
|
209
|
+
const interleaved = input.drainPending();
|
|
210
|
+
if (interleaved.length > 0) {
|
|
211
|
+
log.info(`[pi/session] interleaved ${interleaved.length} mid-turn user message(s) into history`);
|
|
212
|
+
for (const m of interleaved) messages.push(m);
|
|
213
|
+
pendingInterleave = true;
|
|
214
|
+
} else {
|
|
215
|
+
pendingInterleave = false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Exit when the model has nothing more to do AND no new user messages
|
|
219
|
+
// arrived mid-round. Either condition by itself keeps the loop alive.
|
|
220
|
+
if (toolUses.length === 0 && !pendingInterleave) break;
|
|
189
221
|
}
|
|
190
222
|
|
|
191
223
|
if (!turnErrored) {
|
|
@@ -202,7 +234,7 @@ export function createPiSession(init: PiSessionInit): PiSession {
|
|
|
202
234
|
for await (const userMsg of input) {
|
|
203
235
|
if (init.abortController.signal.aborted) break;
|
|
204
236
|
try {
|
|
205
|
-
await runOneTurn(userMsg);
|
|
237
|
+
await runOneTurn(input, userMsg);
|
|
206
238
|
} catch (err: any) {
|
|
207
239
|
log.warn(`[pi/session] Turn failed: ${err?.message || err}`);
|
|
208
240
|
init.onEvent({ type: 'error', error: err?.message || String(err) });
|