pi-onlyne 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -34
- package/README.zh.md +47 -26
- package/package.json +1 -1
- package/src/agent.mjs +175 -49
- package/src/agent.test.mjs +245 -31
- package/src/config.mjs +27 -5
- package/src/index.ts +35 -3
- package/src/protocol.mjs +41 -49
- package/src/protocol.test.mjs +58 -8
package/src/agent.test.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// wire vector, so the input side is byte-identical to what the client sends.
|
|
4
4
|
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
6
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { createServer } from "node:net";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
@@ -12,6 +12,7 @@ import { afterEach, test } from "node:test";
|
|
|
12
12
|
|
|
13
13
|
import { OnlyneAgent } from "./agent.mjs";
|
|
14
14
|
import { MAX_LINES, MAX_WIDTH } from "./activity.mjs";
|
|
15
|
+
import { DEFAULT_IDLE_REMINDERS } from "./config.mjs";
|
|
15
16
|
import { createFrameDecoder, encodeFrame } from "./frame.mjs";
|
|
16
17
|
import { SEQ_BASE, readyReport } from "./protocol.mjs";
|
|
17
18
|
|
|
@@ -22,6 +23,15 @@ const ASSIGN_FRAME = existsSync(VECTOR_DIR)
|
|
|
22
23
|
? JSON.parse(JSON.parse(readFileSync(`${VECTOR_DIR}adapter_host_assign.json`, "utf8")).frame)
|
|
23
24
|
: null;
|
|
24
25
|
const TASK_ID = "11111111-1111-4111-8111-111111111111";
|
|
26
|
+
/** A task the host hands this session, distinct from the id it was spawned with. */
|
|
27
|
+
const ASSIGNED_TASK_ID = "33333333-3333-4333-8333-333333333333";
|
|
28
|
+
/**
|
|
29
|
+
* The answer a real host gives one `handoff`: the child it minted, and the hop
|
|
30
|
+
* that child sits at. The hop is a figure no client can derive — it comes off
|
|
31
|
+
* the parent's causality — so a test asserting it reads the host's own number.
|
|
32
|
+
*/
|
|
33
|
+
const CHILD_TASK_ID = "22222222-2222-4222-8222-222222222222";
|
|
34
|
+
const HANDOFF_ANSWER = { task_id: CHILD_TASK_ID, hop: 3, queued: true, op_id: `o-${CHILD_TASK_ID}` };
|
|
25
35
|
const SESSION_ID = "8b1c";
|
|
26
36
|
const PANE_KEY = "45e603f7-0772-48aa-bcf6-832272747713:b6d067b6-9255-4f5c-a13f-24f194ea0560";
|
|
27
37
|
const PANE_TAB = "45e603f7-0772-48aa-bcf6-832272747713";
|
|
@@ -42,6 +52,34 @@ function heartbeats(host) {
|
|
|
42
52
|
.map((args) => args.data.observed);
|
|
43
53
|
}
|
|
44
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The exact keys of a plugin observation outside a pane. `agent`, `resource` and
|
|
57
|
+
* the reconcile defaults are what the plugin states; `delivery` and `recovery`
|
|
58
|
+
* ride as placeholders the client overwrites with its own records, and no
|
|
59
|
+
* outcome or public view travels in a tuple at all.
|
|
60
|
+
*/
|
|
61
|
+
const OBSERVED_KEYS = [
|
|
62
|
+
"version",
|
|
63
|
+
"generation_live",
|
|
64
|
+
"isolate_after",
|
|
65
|
+
"terminate_after",
|
|
66
|
+
"mismatch_count",
|
|
67
|
+
"agent",
|
|
68
|
+
"delivery",
|
|
69
|
+
"resource",
|
|
70
|
+
"recovery",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The observation's keys with the pane binding discounted: `host` rides along
|
|
75
|
+
* when the process was spawned in an Orca pane, and the test host's own
|
|
76
|
+
* environment decides whether it does.
|
|
77
|
+
*/
|
|
78
|
+
function dimensionKeys(observed) {
|
|
79
|
+
const { host, ...rest } = observed;
|
|
80
|
+
return Object.keys(rest);
|
|
81
|
+
}
|
|
82
|
+
|
|
45
83
|
/** Poll until `predicate` holds, so a test never races the event loop. */
|
|
46
84
|
async function waitFor(predicate, { timeoutMs = 2_000, stepMs = 5 } = {}) {
|
|
47
85
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -63,9 +101,12 @@ async function waitFor(predicate, { timeoutMs = 2_000, stepMs = 5 } = {}) {
|
|
|
63
101
|
* the client has acknowledged the outcome. `failSend` refuses every `send` the
|
|
64
102
|
* way a client with no route to the target would. The ready and heartbeat
|
|
65
103
|
* reports are never held: the handshake waits on them.
|
|
104
|
+
* `handoffError` refuses every `handoff` with that error body, the way a client
|
|
105
|
+
* that cannot find the task the session names would; with none, the fake answers
|
|
106
|
+
* the child the proto documents.
|
|
66
107
|
*/
|
|
67
108
|
class FakeHost {
|
|
68
|
-
constructor({ coalesceAssign = null, holdCompletion = false, failSend = false } = {}) {
|
|
109
|
+
constructor({ coalesceAssign = null, holdCompletion = false, failSend = false, handoffError = null } = {}) {
|
|
69
110
|
this.server = createServer((socket) => this.onConnection(socket));
|
|
70
111
|
this.frames = [];
|
|
71
112
|
this.sockets = new Set();
|
|
@@ -73,6 +114,7 @@ class FakeHost {
|
|
|
73
114
|
this.coalesceAssign = coalesceAssign;
|
|
74
115
|
this.holdCompletion = holdCompletion;
|
|
75
116
|
this.failSend = failSend;
|
|
117
|
+
this.handoffError = handoffError;
|
|
76
118
|
this.held = [];
|
|
77
119
|
}
|
|
78
120
|
|
|
@@ -126,6 +168,11 @@ class FakeHost {
|
|
|
126
168
|
}));
|
|
127
169
|
return;
|
|
128
170
|
}
|
|
171
|
+
if (frame.op === "handoff") {
|
|
172
|
+
body = this.handoffError
|
|
173
|
+
? { ok: false, error: this.handoffError }
|
|
174
|
+
: { ok: true, data: HANDOFF_ANSWER };
|
|
175
|
+
}
|
|
129
176
|
const reply = encodeFrame({ reply_to: frame.id, ...body });
|
|
130
177
|
const push = frame.op === "hello" && this.coalesceAssign
|
|
131
178
|
? encodeFrame({ op: "assign", args: this.coalesceAssign })
|
|
@@ -202,11 +249,12 @@ async function startAgent({
|
|
|
202
249
|
coalesceAssign = null,
|
|
203
250
|
holdCompletion = false,
|
|
204
251
|
failSend = false,
|
|
252
|
+
handoffError = null,
|
|
205
253
|
relay = null,
|
|
206
254
|
} = {}) {
|
|
207
255
|
const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-agent-"));
|
|
208
256
|
const socketPath = join(dir, "s");
|
|
209
|
-
const host = new FakeHost({ coalesceAssign, holdCompletion, failSend });
|
|
257
|
+
const host = new FakeHost({ coalesceAssign, holdCompletion, failSend, handoffError });
|
|
210
258
|
await host.listen(socketPath);
|
|
211
259
|
const logs = [];
|
|
212
260
|
const agent = new OnlyneAgent({
|
|
@@ -298,7 +346,8 @@ test("every heartbeat names the Orca pane this process was spawned in", async ()
|
|
|
298
346
|
});
|
|
299
347
|
// The binding rides beside the state dimensions instead of replacing them.
|
|
300
348
|
assert.equal(observed.agent, "running");
|
|
301
|
-
assert.equal(observed.
|
|
349
|
+
assert.equal(observed.resource, "attached");
|
|
350
|
+
assert.deepEqual(Object.keys(observed), [...OBSERVED_KEYS, "host"]);
|
|
302
351
|
assert.equal(observed.version.generation, 1);
|
|
303
352
|
});
|
|
304
353
|
});
|
|
@@ -535,7 +584,7 @@ test("an assign sharing the hello reply's chunk waits for the welcome", async ()
|
|
|
535
584
|
assert.deepEqual(acks[0], { task_id: TASK_ID, accepted: true });
|
|
536
585
|
});
|
|
537
586
|
|
|
538
|
-
test("turn hooks report running then idle, and
|
|
587
|
+
test("turn hooks report running then idle, and the settle after them re-sends the assignment", async () => {
|
|
539
588
|
const { agent, host, surface } = await startAgent();
|
|
540
589
|
agent.start();
|
|
541
590
|
await waitFor(() => host.of("report").length >= 1);
|
|
@@ -545,31 +594,34 @@ test("turn hooks report running then idle, and a settle completes done with the
|
|
|
545
594
|
agent.onTurnStart();
|
|
546
595
|
const running = await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "running"));
|
|
547
596
|
assert.equal(running.kind, "heartbeat");
|
|
548
|
-
assert.equal(running.data.observed.
|
|
597
|
+
assert.equal(running.data.observed.resource, "attached");
|
|
598
|
+
assert.deepEqual(dimensionKeys(running.data.observed), OBSERVED_KEYS, "a running beat carries exactly the dimensions the plugin can see");
|
|
549
599
|
assert.equal(running.data.observed.version.generation, 1);
|
|
550
600
|
|
|
551
601
|
agent.onTurnEnd();
|
|
552
602
|
const idle = await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "idle"));
|
|
553
|
-
assert.equal(idle.
|
|
603
|
+
assert.equal(idle.kind, "heartbeat");
|
|
604
|
+
assert.deepEqual(dimensionKeys(idle.data.observed), OBSERVED_KEYS, "an idle beat claims no dimension the plugin does not own");
|
|
554
605
|
|
|
555
606
|
agent.noteAssistantText("OK");
|
|
556
607
|
agent.onSettled();
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
assert.
|
|
608
|
+
// A turn that ends without a completion leaves the task open: the settle sends
|
|
609
|
+
// the assignment again rather than reporting an outcome the model never
|
|
610
|
+
// claimed. `onlyne_complete` is the only path to `done`.
|
|
611
|
+
const reminded = await waitFor(() =>
|
|
612
|
+
surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
|
|
613
|
+
);
|
|
614
|
+
assert.match(reminded[1].text, /build it/, "the reminder carries the task text");
|
|
615
|
+
assert.deepEqual(host.of("report").filter((report) => report.kind === "complete"), []);
|
|
616
|
+
assert.deepEqual(surface.calls.exits, [], "a task with a rung left is not failed");
|
|
566
617
|
});
|
|
567
618
|
|
|
568
619
|
// The live case found this ordering too: pi's turn-end hook fires in the same
|
|
569
620
|
// millisecond as the settle that reports the completion, so the turn-end
|
|
570
|
-
// heartbeat lands after the completion report.
|
|
571
|
-
//
|
|
572
|
-
//
|
|
621
|
+
// heartbeat lands after the completion report. The beat no longer carries a
|
|
622
|
+
// delivery or recovery the host would believe — the client composes those from
|
|
623
|
+
// its own records — but a snapshot of an agent dimension nobody is watching any
|
|
624
|
+
// more is still a frame the protocol does not need, so the plugin stays quiet.
|
|
573
625
|
test("a turn-end heartbeat after the completion never leaves the plugin", async () => {
|
|
574
626
|
const { agent, host, surface } = await startAgent();
|
|
575
627
|
agent.start();
|
|
@@ -588,7 +640,7 @@ test("a turn-end heartbeat after the completion never leaves the plugin", async
|
|
|
588
640
|
assert.deepEqual(host.of("report").slice(reports), [], "the completion is the last report");
|
|
589
641
|
});
|
|
590
642
|
|
|
591
|
-
test("a busy pi holds the
|
|
643
|
+
test("a busy pi holds the settle decision until it is idle", async () => {
|
|
592
644
|
let idle = false;
|
|
593
645
|
const surface = fakeSurface({ idle: () => idle });
|
|
594
646
|
const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
|
|
@@ -597,14 +649,95 @@ test("a busy pi holds the completion until it is idle", async () => {
|
|
|
597
649
|
host.notify("assign", assignArgs());
|
|
598
650
|
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
599
651
|
agent.onTurnEnd();
|
|
600
|
-
agent.noteAssistantText("still working");
|
|
601
652
|
agent.onSettled();
|
|
602
653
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
603
|
-
assert.
|
|
654
|
+
assert.equal(surface.calls.wakeUser.length, 1, "a busy pi is not reminded");
|
|
604
655
|
|
|
605
656
|
idle = true;
|
|
657
|
+
const reminded = await waitFor(() =>
|
|
658
|
+
surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
|
|
659
|
+
);
|
|
660
|
+
assert.match(reminded[1].text, /build it/, "the decision waits for the idle it is about");
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// The idle ladder (`docs/SWARM-REFACTOR-GRILLME.md` §4.2): a turn that ends
|
|
664
|
+
// without a completion exit re-sends the assignment, and the idle that finds the
|
|
665
|
+
// bound spent fails the task instead of settling it `done`. The bound is the
|
|
666
|
+
// workspace's (`config.mjs`, two by default) and the count lives on the record
|
|
667
|
+
// `onAssign` wrote for the task, which is why a task can be reminded twice and
|
|
668
|
+
// failed on the third idle without the tool ever being called.
|
|
669
|
+
test("an idle without a completion is reminded, the idle past the bound fails the task, and a completed turn is not reminded", async () => {
|
|
670
|
+
const { agent, host, surface } = await startAgent();
|
|
671
|
+
assert.equal(agent.idleReminders, DEFAULT_IDLE_REMINDERS, "the bound is the workspace's, two by default");
|
|
672
|
+
agent.start();
|
|
673
|
+
await waitFor(() => host.of("report").length >= 1);
|
|
674
|
+
// The assignment carries an image, so the reminder has an attachment path to
|
|
675
|
+
// name and a part it must not hand over twice.
|
|
676
|
+
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
677
|
+
const assignment = structuredClone(assignArgs());
|
|
678
|
+
assignment.envelope.body = {
|
|
679
|
+
text: "build it",
|
|
680
|
+
image: { data_base64: bytes.toString("base64"), mime: "image/png", name: "shot.png" },
|
|
681
|
+
};
|
|
682
|
+
host.notify("assign", assignment);
|
|
683
|
+
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
684
|
+
assert.equal(surface.calls.wakeUser[0].parts.length, 1, "the injection carries the image");
|
|
685
|
+
|
|
686
|
+
// Two idles, two reminders: the assignment comes back whole — header, task
|
|
687
|
+
// text and all — and the model is told why it is seeing it again.
|
|
688
|
+
for (const rung of [1, 2]) {
|
|
689
|
+
agent.onTurnStart();
|
|
690
|
+
agent.onTurnEnd();
|
|
691
|
+
agent.onSettled();
|
|
692
|
+
const sent = await waitFor(() =>
|
|
693
|
+
surface.calls.wakeUser.length === 1 + rung ? surface.calls.wakeUser : null,
|
|
694
|
+
);
|
|
695
|
+
const reminder = sent.at(-1);
|
|
696
|
+
assert.match(reminder.text, new RegExp(`reminder ${rung} of 2`));
|
|
697
|
+
assert.match(reminder.text, /build it/, "the reminder carries the task text");
|
|
698
|
+
assert.match(
|
|
699
|
+
reminder.text,
|
|
700
|
+
/\[onlyne\] task 11111111-1111-4111-8111-111111111111 from role:planner \(kind task\)/,
|
|
701
|
+
"and the identity and source the injection named",
|
|
702
|
+
);
|
|
703
|
+
assert.match(reminder.text, /\[onlyne\] attachment saved to: .*shot\.png/, "the path travels as text");
|
|
704
|
+
assert.deepEqual(reminder.parts, [], "and the image itself is not handed over twice");
|
|
705
|
+
assert.doesNotMatch(reminder.text, /Read the incoming task/, "the role prose is already in the context");
|
|
706
|
+
assert.deepEqual(
|
|
707
|
+
host.of("report").filter((report) => report.kind === "complete"),
|
|
708
|
+
[],
|
|
709
|
+
`rung ${rung}: an open task is not settled while it has a rung left`,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// The third idle: the bound is spent, so the open task fails and the session
|
|
714
|
+
// leaves — the tool was never called, and no completion was ever reported.
|
|
715
|
+
agent.onTurnStart();
|
|
716
|
+
agent.onTurnEnd();
|
|
717
|
+
agent.onSettled();
|
|
606
718
|
const complete = await waitFor(() => host.of("report").find((report) => report.kind === "complete"));
|
|
607
|
-
assert.
|
|
719
|
+
assert.deepEqual(complete.data, {
|
|
720
|
+
task_id: TASK_ID,
|
|
721
|
+
outcome: "failed",
|
|
722
|
+
head: "no completion after 2 idle reminders",
|
|
723
|
+
});
|
|
724
|
+
assert.deepEqual(surface.calls.exits, ["failed"], "the ladder's failure leaves the session");
|
|
725
|
+
assert.equal(surface.calls.wakeUser.length, 3, "the bound is spent: no third reminder");
|
|
726
|
+
|
|
727
|
+
// A turn that hands the outcome over is never reminded: the tool call is the
|
|
728
|
+
// exit the ladder exists to get.
|
|
729
|
+
const second = await startAgent();
|
|
730
|
+
second.agent.start();
|
|
731
|
+
await waitFor(() => second.host.of("report").length >= 1);
|
|
732
|
+
second.host.notify("assign", assignArgs());
|
|
733
|
+
await waitFor(() => (second.surface.calls.wakeUser.length === 1 ? true : null));
|
|
734
|
+
second.agent.onTurnStart();
|
|
735
|
+
second.agent.onTurnEnd();
|
|
736
|
+
await second.agent.completeFromTool({ outcome: "done", text: "built it" });
|
|
737
|
+
second.agent.onSettled();
|
|
738
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
739
|
+
assert.equal(second.surface.calls.wakeUser.length, 1, "the completion is the exit: no reminder follows it");
|
|
740
|
+
assert.deepEqual(second.surface.calls.exits, ["done"]);
|
|
608
741
|
});
|
|
609
742
|
|
|
610
743
|
test("an assigned task that never ran is not completed", async () => {
|
|
@@ -660,9 +793,9 @@ test("an explicit tool outcome wins and a second completion is refused", async (
|
|
|
660
793
|
});
|
|
661
794
|
|
|
662
795
|
// The completion body is what the tool call handed over. The sentence a turn
|
|
663
|
-
// ends on is only the fallback for a
|
|
664
|
-
//
|
|
665
|
-
//
|
|
796
|
+
// ends on is only the fallback for a call whose argument carries nothing, and a
|
|
797
|
+
// call that carries a `text` reports it byte for byte — so the argument can
|
|
798
|
+
// never be displaced, before or after the call.
|
|
666
799
|
test("an explicit tool argument is the head over the last assistant text", async () => {
|
|
667
800
|
const { agent, host, surface } = await startAgent();
|
|
668
801
|
agent.start();
|
|
@@ -767,10 +900,7 @@ test("a completion after a running beat publishes the settled observation before
|
|
|
767
900
|
);
|
|
768
901
|
const settled = reports.at(-1).data.observed;
|
|
769
902
|
assert.equal(settled.agent, "idle");
|
|
770
|
-
assert.
|
|
771
|
-
assert.equal(settled.delivery, "accepted");
|
|
772
|
-
assert.equal(settled.recovery, "draining");
|
|
773
|
-
assert.equal(settled.public, "exited");
|
|
903
|
+
assert.deepEqual(dimensionKeys(settled), OBSERVED_KEYS, "the last observation states the agent and nothing else");
|
|
774
904
|
assert.deepEqual(surface.calls.exits, ["done"]);
|
|
775
905
|
});
|
|
776
906
|
|
|
@@ -821,7 +951,7 @@ test("a queued completion and its settled observation flush in order after recon
|
|
|
821
951
|
],
|
|
822
952
|
"the settled observation rides after the flushed completion: " + JSON.stringify(reports),
|
|
823
953
|
);
|
|
824
|
-
assert.
|
|
954
|
+
assert.deepEqual(dimensionKeys(reports.at(-1).data.observed), OBSERVED_KEYS, "the flushed tail states the agent and nothing else");
|
|
825
955
|
});
|
|
826
956
|
|
|
827
957
|
test("an inbound image is written under the workspace and handed to pi", async () => {
|
|
@@ -1128,6 +1258,90 @@ test("the handoff ledger belongs to the session, not to one task", async () => {
|
|
|
1128
1258
|
assert.equal(completions(host).length, 1);
|
|
1129
1259
|
});
|
|
1130
1260
|
|
|
1261
|
+
// ------------------------------------------------------------ the handoff tool
|
|
1262
|
+
|
|
1263
|
+
test("a handoff names the task this session holds and hands the answer back", async () => {
|
|
1264
|
+
const { agent, host } = await startAgent();
|
|
1265
|
+
agent.start();
|
|
1266
|
+
await waitFor(() => host.of("report").length >= 1);
|
|
1267
|
+
|
|
1268
|
+
// The task the host handed this session is the family's parent, so that id is
|
|
1269
|
+
// the one the request names: the spawn id stays behind it.
|
|
1270
|
+
const args = structuredClone(assignArgs());
|
|
1271
|
+
args.task_id = ASSIGNED_TASK_ID;
|
|
1272
|
+
args.envelope.causality.task = ASSIGNED_TASK_ID;
|
|
1273
|
+
host.notify("assign", args);
|
|
1274
|
+
await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
|
|
1275
|
+
|
|
1276
|
+
const result = await agent.handoffFromTool({ to: "builder", text: "take it from here" });
|
|
1277
|
+
assert.deepEqual(host.of("handoff"), [
|
|
1278
|
+
{ task_id: ASSIGNED_TASK_ID, to: "builder", text: "take it from here", image: null },
|
|
1279
|
+
]);
|
|
1280
|
+
assert.deepEqual(result, {
|
|
1281
|
+
taskId: CHILD_TASK_ID,
|
|
1282
|
+
hop: HANDOFF_ANSWER.hop,
|
|
1283
|
+
queued: true,
|
|
1284
|
+
opId: `o-${CHILD_TASK_ID}`,
|
|
1285
|
+
to: "builder",
|
|
1286
|
+
});
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
test("a handoff reads the image the model named and types it from its path", async () => {
|
|
1290
|
+
const { agent, host, dir } = await startAgent();
|
|
1291
|
+
agent.start();
|
|
1292
|
+
await waitFor(() => host.of("report").length >= 1);
|
|
1293
|
+
|
|
1294
|
+
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
1295
|
+
const shot = join(dir, "shot.png");
|
|
1296
|
+
writeFileSync(shot, bytes);
|
|
1297
|
+
await agent.handoffFromTool({ to: "builder", text: "the shot", imagePath: shot });
|
|
1298
|
+
assert.deepEqual(host.of("handoff")[0].image, {
|
|
1299
|
+
data_base64: bytes.toString("base64"),
|
|
1300
|
+
mime: "image/png",
|
|
1301
|
+
name: "shot.png",
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
// A path this plugin cannot type is refused here, so no half-typed handoff
|
|
1305
|
+
// reaches the wire.
|
|
1306
|
+
const bad = join(dir, "shot.bmp");
|
|
1307
|
+
writeFileSync(bad, bytes);
|
|
1308
|
+
await assert.rejects(
|
|
1309
|
+
() => agent.handoffFromTool({ to: "builder", text: "the shot", imagePath: bad }),
|
|
1310
|
+
/unsupported image type/,
|
|
1311
|
+
);
|
|
1312
|
+
assert.equal(host.of("handoff").length, 1);
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
test("a send types its image from the path too", async () => {
|
|
1316
|
+
const { agent, host, dir } = await startAgent();
|
|
1317
|
+
agent.start();
|
|
1318
|
+
await waitFor(() => host.of("report").length >= 1);
|
|
1319
|
+
|
|
1320
|
+
const path = join(dir, "diagram.webp");
|
|
1321
|
+
writeFileSync(path, Buffer.from([1, 2, 3]));
|
|
1322
|
+
await agent.sendFromTool({ to: "writer", text: "the diagram", imagePath: path });
|
|
1323
|
+
|
|
1324
|
+
assert.deepEqual(host.of("send")[0].body.image, {
|
|
1325
|
+
data_base64: "AQID",
|
|
1326
|
+
mime: "image/webp",
|
|
1327
|
+
name: "diagram.webp",
|
|
1328
|
+
});
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
test("a handoff the client refuses throws the client's own reason", async () => {
|
|
1332
|
+
const { agent, host } = await startAgent({
|
|
1333
|
+
handoffError: { code: "no_family", message: "no task family for that id" },
|
|
1334
|
+
});
|
|
1335
|
+
agent.start();
|
|
1336
|
+
await waitFor(() => host.of("report").length >= 1);
|
|
1337
|
+
|
|
1338
|
+
await assert.rejects(
|
|
1339
|
+
() => agent.handoffFromTool({ to: "builder", text: "take it" }),
|
|
1340
|
+
/no_family: no task family for that id/,
|
|
1341
|
+
);
|
|
1342
|
+
assert.equal(host.of("handoff").length, 1, "a refusal is not retried");
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1131
1345
|
// ---------------------------------------------------------------- the panel
|
|
1132
1346
|
|
|
1133
1347
|
test("a pi with the widget gets the panel and keeps a quiet scrollback", async () => {
|
package/src/config.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// generate-time template advice), so the only consumer is this extension. A
|
|
7
7
|
// malformed or missing file falls back to the defaults and reports a warning
|
|
8
8
|
// instead of disabling the session: the extension's own `enabled` key is the one
|
|
9
|
-
// deliberate off switch.
|
|
9
|
+
// deliberate off switch. A key whose value is unusable — the idle bound below,
|
|
10
|
+
// say — keeps the one default it names and leaves the rest of the file alone.
|
|
10
11
|
|
|
11
12
|
import { readFileSync } from "node:fs";
|
|
12
13
|
import { join } from "node:path";
|
|
@@ -14,15 +15,26 @@ import { join } from "node:path";
|
|
|
14
15
|
/** Where the switch file lives, relative to the pi working directory. */
|
|
15
16
|
export const CONFIG_RELATIVE_PATH = join(".pi", "onlyne.json");
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* How many idle reminders one task may collect before the ladder fails it
|
|
20
|
+
* (`agent.mjs` `settleNow`): two, so the third idle without a completion is the
|
|
21
|
+
* failure.
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_IDLE_REMINDERS = 2;
|
|
24
|
+
|
|
25
|
+
/** Defaults: on, connecting as soon as a session starts, and the idle bound. */
|
|
26
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
27
|
+
enabled: true,
|
|
28
|
+
autoStart: true,
|
|
29
|
+
idleReminders: DEFAULT_IDLE_REMINDERS,
|
|
30
|
+
});
|
|
19
31
|
|
|
20
32
|
/**
|
|
21
33
|
* Read `.pi/onlyne.json`.
|
|
22
34
|
*
|
|
23
35
|
* @param {string} cwd
|
|
24
36
|
* @param {{ readFile?: (path: string) => string }} [options]
|
|
25
|
-
* @returns {{ enabled: boolean, autoStart: boolean, path: string, warning: string | null, present: boolean }}
|
|
37
|
+
* @returns {{ enabled: boolean, autoStart: boolean, idleReminders: number, path: string, warning: string | null, present: boolean }}
|
|
26
38
|
*/
|
|
27
39
|
export function loadConfig(cwd, options = {}) {
|
|
28
40
|
const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
|
|
@@ -48,11 +60,21 @@ export function loadConfig(cwd, options = {}) {
|
|
|
48
60
|
return { ...DEFAULT_CONFIG, path, warning: `${path} must hold a JSON object; using defaults`, present: true };
|
|
49
61
|
}
|
|
50
62
|
const watch = parsed.watch && typeof parsed.watch === "object" ? parsed.watch : {};
|
|
63
|
+
// The bound is a count, so only a non-negative integer is a value: a string,
|
|
64
|
+
// a fraction or a negative would either count nothing or count forever.
|
|
65
|
+
// Zero is a value — it says the first idle without a completion is already
|
|
66
|
+
// the failure — and it is the operator's call to make.
|
|
67
|
+
const idleReminders = parsed.idleReminders;
|
|
68
|
+
const usable = Number.isInteger(idleReminders) && idleReminders >= 0;
|
|
51
69
|
return {
|
|
52
70
|
enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_CONFIG.enabled,
|
|
53
71
|
autoStart: typeof watch.autoStart === "boolean" ? watch.autoStart : DEFAULT_CONFIG.autoStart,
|
|
72
|
+
idleReminders: usable ? idleReminders : DEFAULT_CONFIG.idleReminders,
|
|
54
73
|
path,
|
|
55
|
-
warning:
|
|
74
|
+
warning:
|
|
75
|
+
idleReminders !== undefined && !usable
|
|
76
|
+
? `${path} idleReminders must be a non-negative integer; using ${DEFAULT_CONFIG.idleReminders}`
|
|
77
|
+
: null,
|
|
56
78
|
present: true,
|
|
57
79
|
};
|
|
58
80
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
//
|
|
10
10
|
// session_start -> read env + .pi/onlyne.json, connect, register tools
|
|
11
11
|
// turn_start -> heartbeat{running}
|
|
12
|
-
// turn_end -> heartbeat{idle}
|
|
12
|
+
// turn_end -> heartbeat{idle}; the settle window opens
|
|
13
13
|
// message_end -> keep the last assistant text; a failed turn is `failed`
|
|
14
|
-
// agent_settled ->
|
|
14
|
+
// agent_settled -> settle decision: the idle ladder, or `failed` at once
|
|
15
15
|
// session_shutdown -> detach{reason}
|
|
16
16
|
|
|
17
17
|
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -165,10 +165,11 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
165
165
|
name: "onlyne_complete",
|
|
166
166
|
label: "Onlyne complete",
|
|
167
167
|
description:
|
|
168
|
-
"End this onlyne task with an explicit outcome. Call it once, when the assigned work is finished (outcome=done), provably impossible (outcome=failed), or withdrawn (outcome=cancelled).
|
|
168
|
+
"End this onlyne task with an explicit outcome. Call it once, when the assigned work is finished (outcome=done), provably impossible (outcome=failed), or withdrawn (outcome=cancelled). This call is the only way the task reaches done: a turn that ends without it leaves the task open, the session re-sends you the assignment up to the workspace's idle-reminder bound, and the idle that finds the bound spent fails the task and ends the session. In a workspace whose relay policy (relay.toml) names the handoffs this session owes, the call is refused until each one has gone out.",
|
|
169
169
|
promptSnippet: "Finish the current onlyne task with an outcome and a one-line summary",
|
|
170
170
|
promptGuidelines: [
|
|
171
171
|
"Use onlyne_complete at the end of an onlyne task, naming the outcome and the result in one line; the summary becomes the ledger head.",
|
|
172
|
+
"If the assignment is sent to you again while it is still open, the previous turn ended without a completion: finish the work and call onlyne_complete.",
|
|
172
173
|
"If onlyne_complete answers 'relay guard', the session still owes a downstream handoff: make it with onlyne_send and call onlyne_complete again. Close the session anyway only when the handoff is genuinely impossible, with force: true and a reason.",
|
|
173
174
|
],
|
|
174
175
|
parameters: Type.Object({
|
|
@@ -196,6 +197,36 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
196
197
|
} catch (error) {
|
|
197
198
|
log(`registerTool(onlyne_complete) refused: ${error instanceof Error ? error.message : String(error)}`);
|
|
198
199
|
}
|
|
200
|
+
try {
|
|
201
|
+
pi.registerTool(defineTool({
|
|
202
|
+
name: "onlyne_handoff",
|
|
203
|
+
label: "Onlyne handoff",
|
|
204
|
+
description:
|
|
205
|
+
"Hand this session's task on to the next hop of its family. The host mints one child task for the named role, names this task as the child's parent_task, raises the hop by one, and lets the family's budget, labels, origin and deadline ride along, so the child continues the run this session serves. Use it for the next slot of a ring or a chain; onlyne_send{kind:\"task\"} starts a new family at hop 0, and onlyne_send{kind:\"note\"} is free text.",
|
|
206
|
+
promptSnippet: "Hand this task on to the next role of its family",
|
|
207
|
+
promptGuidelines: [
|
|
208
|
+
"Use onlyne_handoff when the work goes on to the next role of the run this session serves: the child the host mints carries the same family id, hop budget, labels, origin and deadline, and this task becomes its parent_task.",
|
|
209
|
+
"Use onlyne_send with kind=\"task\" when a role should get work of its own: that child is hop 0 of a family this session starts.",
|
|
210
|
+
"Use onlyne_send with kind=\"note\" for free text to a role, which carries no task and no hop.",
|
|
211
|
+
],
|
|
212
|
+
parameters: Type.Object({
|
|
213
|
+
to: Type.String({ description: "target role name, e.g. builder" }),
|
|
214
|
+
text: Type.String({ description: "handoff text for the next role of the run" }),
|
|
215
|
+
image: Type.Optional(Type.String({ description: "absolute path to a png/jpeg/gif/webp image to attach" })),
|
|
216
|
+
}),
|
|
217
|
+
async execute(_toolCallId, params) {
|
|
218
|
+
if (!agent) throw new Error("onlyne: session is not connected");
|
|
219
|
+
const result = await agent.handoffFromTool({
|
|
220
|
+
to: params.to,
|
|
221
|
+
text: params.text,
|
|
222
|
+
imagePath: params.image ?? null,
|
|
223
|
+
});
|
|
224
|
+
return textResult(`handed on to ${result.to} as ${result.taskId} at hop ${result.hop}`, result);
|
|
225
|
+
},
|
|
226
|
+
}));
|
|
227
|
+
} catch (error) {
|
|
228
|
+
log(`registerTool(onlyne_handoff) refused: ${error instanceof Error ? error.message : String(error)}`);
|
|
229
|
+
}
|
|
199
230
|
try {
|
|
200
231
|
pi.registerCommand("onlyne", {
|
|
201
232
|
description: "Onlyne session status: connection, task, reports",
|
|
@@ -256,6 +287,7 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
256
287
|
taskId: identity.taskId,
|
|
257
288
|
surface,
|
|
258
289
|
relay,
|
|
290
|
+
idleReminders: config.idleReminders,
|
|
259
291
|
log,
|
|
260
292
|
});
|
|
261
293
|
log(`session ${identity.sessionId} role=${identity.role} socket=${socketPath}`);
|