@earendil-works/pi-durable 0.99.0 → 0.99.2
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 +435 -46
- package/dist/entries.d.ts +20 -2
- package/dist/entries.d.ts.map +1 -1
- package/dist/entries.js +14 -1
- package/dist/entries.js.map +1 -1
- package/dist/env/index.d.ts +13 -38
- package/dist/env/index.d.ts.map +1 -1
- package/dist/env/index.js +2 -0
- package/dist/env/index.js.map +1 -1
- package/dist/env/node.d.ts.map +1 -1
- package/dist/env/node.js +51 -58
- package/dist/env/node.js.map +1 -1
- package/dist/harness/compaction.d.ts +56 -0
- package/dist/harness/compaction.d.ts.map +1 -0
- package/dist/harness/compaction.js +385 -0
- package/dist/harness/compaction.js.map +1 -0
- package/dist/harness/config.d.ts +10 -1
- package/dist/harness/config.d.ts.map +1 -1
- package/dist/harness/config.js +6 -0
- package/dist/harness/config.js.map +1 -1
- package/dist/harness/context.d.ts +8 -0
- package/dist/harness/context.d.ts.map +1 -1
- package/dist/harness/context.js +32 -21
- package/dist/harness/context.js.map +1 -1
- package/dist/harness/events.d.ts +166 -0
- package/dist/harness/events.d.ts.map +1 -0
- package/dist/harness/events.js +319 -0
- package/dist/harness/events.js.map +1 -0
- package/dist/harness/generation.d.ts +37 -6
- package/dist/harness/generation.d.ts.map +1 -1
- package/dist/harness/generation.js +350 -72
- package/dist/harness/generation.js.map +1 -1
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +124 -3
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/inbox.d.ts +57 -0
- package/dist/harness/inbox.d.ts.map +1 -0
- package/dist/harness/inbox.js +99 -0
- package/dist/harness/inbox.js.map +1 -0
- package/dist/harness/json.d.ts +10 -0
- package/dist/harness/json.d.ts.map +1 -0
- package/dist/harness/json.js +33 -0
- package/dist/harness/json.js.map +1 -0
- package/dist/harness/live.d.ts +66 -7
- package/dist/harness/live.d.ts.map +1 -1
- package/dist/harness/live.js +71 -7
- package/dist/harness/live.js.map +1 -1
- package/dist/harness/output.d.ts +62 -0
- package/dist/harness/output.d.ts.map +1 -0
- package/dist/harness/output.js +261 -0
- package/dist/harness/output.js.map +1 -0
- package/dist/harness/prompt.d.ts +10 -5
- package/dist/harness/prompt.d.ts.map +1 -1
- package/dist/harness/prompt.js +66 -13
- package/dist/harness/prompt.js.map +1 -1
- package/dist/harness/registry.d.ts.map +1 -1
- package/dist/harness/registry.js +11 -2
- package/dist/harness/registry.js.map +1 -1
- package/dist/harness/scheduler.d.ts +37 -12
- package/dist/harness/scheduler.d.ts.map +1 -1
- package/dist/harness/scheduler.js +632 -72
- package/dist/harness/scheduler.js.map +1 -1
- package/dist/harness/submissions.d.ts +12 -7
- package/dist/harness/submissions.d.ts.map +1 -1
- package/dist/harness/submissions.js +72 -33
- package/dist/harness/submissions.js.map +1 -1
- package/dist/harness/tool.d.ts +36 -0
- package/dist/harness/tool.d.ts.map +1 -0
- package/dist/harness/tool.js +412 -0
- package/dist/harness/tool.js.map +1 -0
- package/dist/harness/types.d.ts +185 -11
- package/dist/harness/types.d.ts.map +1 -1
- package/dist/harness/types.js.map +1 -1
- package/dist/harness/usage.d.ts +18 -0
- package/dist/harness/usage.d.ts.map +1 -0
- package/dist/harness/usage.js +57 -0
- package/dist/harness/usage.js.map +1 -0
- package/dist/harness/view.d.ts +43 -0
- package/dist/harness/view.d.ts.map +1 -0
- package/dist/harness/view.js +194 -0
- package/dist/harness/view.js.map +1 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/session/observation.d.ts +18 -11
- package/dist/session/observation.d.ts.map +1 -1
- package/dist/session/observation.js +24 -13
- package/dist/session/observation.js.map +1 -1
- package/dist/session/session.d.ts +10 -1
- package/dist/session/session.d.ts.map +1 -1
- package/dist/session/session.js +18 -4
- package/dist/session/session.js.map +1 -1
- package/dist/session/transaction.d.ts +10 -1
- package/dist/session/transaction.d.ts.map +1 -1
- package/dist/session/transaction.js +83 -27
- package/dist/session/transaction.js.map +1 -1
- package/dist/storage/jsonl/storage.d.ts +22 -2
- package/dist/storage/jsonl/storage.d.ts.map +1 -1
- package/dist/storage/jsonl/storage.js +1 -1
- package/dist/storage/jsonl/storage.js.map +1 -1
- package/dist/storage/memory.js +1 -1
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite/migrations.js +1 -1
- package/dist/storage/sqlite/migrations.js.map +1 -1
- package/dist/testing/storage-benchmark.d.ts.map +1 -1
- package/dist/testing/storage-benchmark.js +0 -1
- package/dist/testing/storage-benchmark.js.map +1 -1
- package/dist/testing/storage-conformance.d.ts.map +1 -1
- package/dist/testing/storage-conformance.js +34 -2
- package/dist/testing/storage-conformance.js.map +1 -1
- package/dist/tools/bash.d.ts +28 -0
- package/dist/tools/bash.d.ts.map +1 -0
- package/dist/tools/bash.js +69 -0
- package/dist/tools/bash.js.map +1 -0
- package/dist/tools/edit-diff.d.ts +89 -0
- package/dist/tools/edit-diff.d.ts.map +1 -0
- package/dist/tools/edit-diff.js +386 -0
- package/dist/tools/edit-diff.js.map +1 -0
- package/dist/tools/edit.d.ts +18 -0
- package/dist/tools/edit.d.ts.map +1 -0
- package/dist/tools/edit.js +116 -0
- package/dist/tools/edit.js.map +1 -0
- package/dist/tools/env.d.ts +5 -0
- package/dist/tools/env.d.ts.map +1 -0
- package/dist/tools/env.js +7 -0
- package/dist/tools/env.js.map +1 -0
- package/dist/tools/file-mutation-queue.d.ts +5 -0
- package/dist/tools/file-mutation-queue.d.ts.map +1 -0
- package/dist/tools/file-mutation-queue.js +46 -0
- package/dist/tools/file-mutation-queue.js.map +1 -0
- package/dist/tools/image.d.ts +2 -0
- package/dist/tools/image.d.ts.map +1 -0
- package/dist/tools/image.js +92 -0
- package/dist/tools/image.js.map +1 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/path-utils.d.ts +5 -0
- package/dist/tools/path-utils.d.ts.map +1 -0
- package/dist/tools/path-utils.js +26 -0
- package/dist/tools/path-utils.js.map +1 -0
- package/dist/tools/read.d.ts +17 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/read.js +100 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/write.d.ts +10 -0
- package/dist/tools/write.d.ts.map +1 -0
- package/dist/tools/write.js +30 -0
- package/dist/tools/write.js.map +1 -0
- package/dist/{env/utils/truncate.d.ts → truncate.d.ts} +1 -17
- package/dist/truncate.d.ts.map +1 -0
- package/dist/truncate.js +146 -0
- package/dist/truncate.js.map +1 -0
- package/dist/types.d.ts +86 -19
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/docs/pico-v5-handoff.md +366 -86
- package/docs/pico-v5.md +1373 -283
- package/package.json +11 -3
- package/dist/env/utils/adaptive-publisher.d.ts +0 -24
- package/dist/env/utils/adaptive-publisher.d.ts.map +0 -1
- package/dist/env/utils/adaptive-publisher.js +0 -79
- package/dist/env/utils/adaptive-publisher.js.map +0 -1
- package/dist/env/utils/output-capture.d.ts +0 -31
- package/dist/env/utils/output-capture.d.ts.map +0 -1
- package/dist/env/utils/output-capture.js +0 -238
- package/dist/env/utils/output-capture.js.map +0 -1
- package/dist/env/utils/truncate.d.ts.map +0 -1
- package/dist/env/utils/truncate.js +0 -294
- package/dist/env/utils/truncate.js.map +0 -1
package/docs/pico-v5.md
CHANGED
|
@@ -22,11 +22,13 @@ import type {
|
|
|
22
22
|
ModelThinkingLevel,
|
|
23
23
|
TextContent,
|
|
24
24
|
Tool,
|
|
25
|
+
ToolCall,
|
|
25
26
|
ToolReference,
|
|
26
27
|
ToolResultMessage,
|
|
27
28
|
Transport,
|
|
28
29
|
UserMessage,
|
|
29
30
|
} from "@earendil-works/pi-ai";
|
|
31
|
+
import type { ExecutionEnv } from "@earendil-works/pi-durable/env";
|
|
30
32
|
|
|
31
33
|
type JsonObject = { [key: string]: JsonValue };
|
|
32
34
|
type TaskOutcomeError = { message: string; detail?: JsonValue };
|
|
@@ -340,6 +342,8 @@ type AnyTask = {
|
|
|
340
342
|
type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = {
|
|
341
343
|
readonly models: Models; // the pi-ai Models interface
|
|
342
344
|
readonly registry: RegistryReader<Tool>; // section 7.1
|
|
345
|
+
/** Default execution environment offered to tools (section 7.3). */
|
|
346
|
+
readonly env?: ExecutionEnv;
|
|
343
347
|
readonly now?: () => number;
|
|
344
348
|
readonly onReport?: (error: unknown) => void;
|
|
345
349
|
};
|
|
@@ -365,12 +369,37 @@ type ConversationRetryPolicy = {
|
|
|
365
369
|
maxAgentDelayMs?: number;
|
|
366
370
|
};
|
|
367
371
|
|
|
372
|
+
/** Automatic compaction thresholds (section 8.7); manual compaction ignores `enabled`. */
|
|
373
|
+
type CompactionPolicy = {
|
|
374
|
+
/** Threshold and overflow compaction. */
|
|
375
|
+
enabled: boolean;
|
|
376
|
+
/** Room kept free for the answer: generation blocks to compact above `contextWindow - reserveTokens`. */
|
|
377
|
+
reserveTokens: number;
|
|
378
|
+
/** Approximate size of the recent context a summary keeps verbatim. */
|
|
379
|
+
keepRecentTokens: number;
|
|
380
|
+
/** Background compaction starts `backgroundTokens` below the blocking threshold; `0` disables it. */
|
|
381
|
+
backgroundTokens: number;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
type ToolExecutionMode = "parallel" | "sequential";
|
|
385
|
+
|
|
386
|
+
/** How many queued items of one mode a boundary selects (section 6). */
|
|
387
|
+
type QueueMode = "all" | "one-at-a-time";
|
|
388
|
+
|
|
368
389
|
type ConversationConfigState = {
|
|
369
390
|
model?: ModelRef;
|
|
370
391
|
thinkingLevel: ModelThinkingLevel;
|
|
371
392
|
activeTools: string[];
|
|
372
393
|
streamOptions?: ConversationStreamOptions;
|
|
373
394
|
retry?: ConversationRetryPolicy;
|
|
395
|
+
/** Default `parallel`; see section 8.3. */
|
|
396
|
+
toolExecution?: ToolExecutionMode;
|
|
397
|
+
/** Default `one-at-a-time`; see section 6. */
|
|
398
|
+
steeringMode?: QueueMode;
|
|
399
|
+
/** Default `one-at-a-time`; see section 6. */
|
|
400
|
+
followUpMode?: QueueMode;
|
|
401
|
+
/** Default `DEFAULT_COMPACTION_POLICY`; see section 8.7. */
|
|
402
|
+
compaction?: CompactionPolicy;
|
|
374
403
|
};
|
|
375
404
|
|
|
376
405
|
/** Built-in rewindable configuration document; see below. */
|
|
@@ -393,6 +422,8 @@ function defineEntry<D extends JsonValue = never>(kind: string): Entry<D>;
|
|
|
393
422
|
type ContextView = {
|
|
394
423
|
readonly head: EntryRecord | undefined;
|
|
395
424
|
readonly entries: readonly EntryRecord[];
|
|
425
|
+
/** Per entry of `entries`, its model messages after edits and rule 9, before rules 7 and 8. */
|
|
426
|
+
readonly contributions: readonly (readonly Message[])[];
|
|
396
427
|
readonly messages: readonly Message[];
|
|
397
428
|
};
|
|
398
429
|
|
|
@@ -417,6 +448,8 @@ type TaskInspection = {
|
|
|
417
448
|
| { readonly kind: "running" }
|
|
418
449
|
| { readonly kind: "ready"; readonly migrates: boolean }
|
|
419
450
|
| { readonly kind: "waiting"; readonly on: readonly TaskId[] }
|
|
451
|
+
/** Outcome held until its ordinary owned work drains (section 5.5). */
|
|
452
|
+
| { readonly kind: "completing" }
|
|
420
453
|
| {
|
|
421
454
|
readonly kind: "blocked";
|
|
422
455
|
readonly reason: "missing_task" | "task_too_old" | "migration_failed";
|
|
@@ -452,6 +485,14 @@ interface Conversation {
|
|
|
452
485
|
setStreamOptions(options: ConversationStreamOptions, context: Context): Promise<void>;
|
|
453
486
|
getRetryPolicy(context: Context): Promise<ConversationRetryPolicy>;
|
|
454
487
|
setRetryPolicy(policy: ConversationRetryPolicy | undefined, context: Context): Promise<void>;
|
|
488
|
+
getToolExecution(context: Context): Promise<ToolExecutionMode>;
|
|
489
|
+
setToolExecution(mode: ToolExecutionMode | undefined, context: Context): Promise<void>;
|
|
490
|
+
getSteeringMode(context: Context): Promise<QueueMode>;
|
|
491
|
+
setSteeringMode(mode: QueueMode | undefined, context: Context): Promise<void>;
|
|
492
|
+
getFollowUpMode(context: Context): Promise<QueueMode>;
|
|
493
|
+
setFollowUpMode(mode: QueueMode | undefined, context: Context): Promise<void>;
|
|
494
|
+
getCompaction(context: Context): Promise<CompactionPolicy>;
|
|
495
|
+
setCompaction(policy: CompactionPolicy | undefined, context: Context): Promise<void>;
|
|
455
496
|
|
|
456
497
|
commit<T>(
|
|
457
498
|
change: (tx: Tx) => T | Promise<T>,
|
|
@@ -469,9 +510,10 @@ interface Conversation {
|
|
|
469
510
|
options: ConversationCreateOptions,
|
|
470
511
|
context: Context,
|
|
471
512
|
): Promise<Conversation>;
|
|
472
|
-
|
|
513
|
+
compact(instructions: string | undefined, context: Context): Promise<TaskId<CompactionResult>>;
|
|
473
514
|
reset(handoff: string | undefined, context: Context): Promise<void>;
|
|
474
|
-
|
|
515
|
+
/** `{ background: true }` also aborts background work under the conversation (section 5.4). */
|
|
516
|
+
abort(context: Context, options?: { readonly background?: boolean }): Promise<void>;
|
|
475
517
|
waitForIdle(context: Context): Promise<void>;
|
|
476
518
|
viewState(context: Context): Promise<AttachedReplicatedState<ConversationView>>;
|
|
477
519
|
watch(context: Context): Promise<ConversationWatch>;
|
|
@@ -498,6 +540,8 @@ interface Harness extends Session {
|
|
|
498
540
|
abortTask(id: TaskId, context: Context): Promise<"marked" | "terminal">;
|
|
499
541
|
waitForTask<R>(id: TaskId<R>, context: Context): Promise<SettledTask<R>>;
|
|
500
542
|
waitForIdle(context: Context): Promise<void>;
|
|
543
|
+
/** Session total of every conversation's `pi.usage` (section 8.6). */
|
|
544
|
+
usage(context: Context): Promise<UsageState>;
|
|
501
545
|
}
|
|
502
546
|
|
|
503
547
|
declare const Harness: {
|
|
@@ -520,9 +564,10 @@ settlement.
|
|
|
520
564
|
7.1). `createRegistry()` pre-registers the built-in task definitions (section 8);
|
|
521
565
|
they cannot be disposed or replaced, and open rejects a registry whose snapshot
|
|
522
566
|
lacks any of them or the built-in `pi` conversation setup.
|
|
523
|
-
Open changes surviving `running` tasks to `pending
|
|
524
|
-
|
|
525
|
-
|
|
567
|
+
Open changes surviving `running` tasks to `pending`, keeps `waiting` tasks
|
|
568
|
+
waiting, and re-evaluates finalization and `failFast` for `completing` and
|
|
569
|
+
`waiting` tasks (section 5.5). It never migrates or terminalizes a task because
|
|
570
|
+
its definition is missing or unmigratable. Such a task stays `pending` or `waiting` and is **blocked**: the
|
|
526
571
|
scheduler skips it and reconsiders it whenever the registry changes (section
|
|
527
572
|
5.4). Any built-in document touched by recovery migrates through its ordinary
|
|
528
573
|
typed access path. Open does not scan or migrate other documents. No handler
|
|
@@ -540,7 +585,7 @@ no configured model produces a durable `no_model` generation failure.
|
|
|
540
585
|
repeat open-time reconciliation, and it throws after close. Work that must happen
|
|
541
586
|
before any task runs, such as registration or seeding, happens before
|
|
542
587
|
`resume()`. Calls that ask for progress also enable scheduling, so they never
|
|
543
|
-
wait on a paused Harness: `Conversation.submit()`, `Submission.wait()`,
|
|
588
|
+
wait on a paused Harness: `Conversation.submit()`, `Conversation.compact()`, `Submission.wait()`,
|
|
544
589
|
`Harness.waitForTask()`, `Harness.waitForIdle()`, and
|
|
545
590
|
`Conversation.waitForIdle()`. Recovered work starts with them. A viewer that only
|
|
546
591
|
reads never enables scheduling.
|
|
@@ -564,9 +609,9 @@ conversation setups (section 7.1) in the same commit, whether through the
|
|
|
564
609
|
conveniences or through raw `tx.createConversation()` and `tx.forkConversation()`,
|
|
565
610
|
for example inside a tool commit. The built-in `pi` setup runs first: an
|
|
566
611
|
independent conversation gets the default configuration and a fork keeps its
|
|
567
|
-
`asOf` copy, and both get
|
|
568
|
-
their own documents the same way. The
|
|
569
|
-
which run after every setup. A conversation created by a plain Session has no
|
|
612
|
+
`asOf` copy, and both get empty `pi.live`, `pi.inbox`, and `pi.usage`.
|
|
613
|
+
Applications register setups for their own documents the same way. The
|
|
614
|
+
conveniences add only `init` and its checks, which run after every setup. A conversation created by a plain Session has no
|
|
570
615
|
built-in documents; its configuration reads as
|
|
571
616
|
`ConversationConfig.definition.initial()` until something writes it.
|
|
572
617
|
|
|
@@ -590,6 +635,13 @@ fork. The getters return immutable committed values, falling back to
|
|
|
590
635
|
returns `{}` and `getRetryPolicy()` returns the default policy
|
|
591
636
|
`{ enabled: true, maxRetries: 3, baseDelayMs: 2000, maxAgentDelayMs: 60000 }`
|
|
592
637
|
when the field is absent; `setRetryPolicy(undefined)` removes the field.
|
|
638
|
+
`getToolExecution()` returns `parallel` when the field is absent;
|
|
639
|
+
`setToolExecution(undefined)` removes it. `getSteeringMode()` and
|
|
640
|
+
`getFollowUpMode()` return `one-at-a-time` when their field is absent; their
|
|
641
|
+
setters remove the field for `undefined`. `getCompaction()` returns
|
|
642
|
+
`DEFAULT_COMPACTION_POLICY`, `{ enabled: true, reserveTokens: 16384,
|
|
643
|
+
keepRecentTokens: 20000, backgroundTokens: 32768 }`, when the field is absent;
|
|
644
|
+
`setCompaction(undefined)` removes it.
|
|
593
645
|
`streamOptions` are forwarded to every generation request of the conversation;
|
|
594
646
|
`streamOptions.maxRetries` are provider retries inside one request, while `retry`
|
|
595
647
|
governs durable generation attempts (section 8). Each setter performs
|
|
@@ -607,10 +659,10 @@ not registered. The create/fork conveniences check only one thing about `init`
|
|
|
607
659
|
writes: names that `init` newly activates must be registered. Names that were
|
|
608
660
|
already active are never revalidated, so stale unregistered names survive every
|
|
609
661
|
edit. All other raw document writes are trusted and unchecked, so other writers,
|
|
610
|
-
such as
|
|
662
|
+
such as the generation's `addTools` handling, never fault because of registry movement. Readers tolerate what raw
|
|
611
663
|
writes can produce: request preparation offers the first occurrence of a
|
|
612
|
-
duplicated name, and
|
|
613
|
-
active.
|
|
664
|
+
duplicated name, and the generation's `tools` phase appends only `addTools`
|
|
665
|
+
names not already active.
|
|
614
666
|
|
|
615
667
|
`init` runs after the conversation creation, which is a table write, so table
|
|
616
668
|
reads inside it throw `ReadAfterWrite` (section 4). Document access remains
|
|
@@ -628,10 +680,18 @@ A missing tool implementation never fails a request. Request preparation offers
|
|
|
628
680
|
only active names that are currently registered. If the replayed tool state still
|
|
629
681
|
offers an unregistered name, the appended system delta lists it in
|
|
630
682
|
`toolsRemoved`; when the name is registered again, a later delta adds its current
|
|
631
|
-
declaration. When the model calls a tool that
|
|
632
|
-
implementation is
|
|
633
|
-
|
|
634
|
-
|
|
683
|
+
declaration. When the model calls a tool that its request did not offer, or whose
|
|
684
|
+
implementation is unregistered when its tool task runs, an error tool result with
|
|
685
|
+
an error diagnostic with code `tool_unavailable` (section 7.3) states that the tool is not available,
|
|
686
|
+
and the run continues so the model can react (section 8.3).
|
|
687
|
+
|
|
688
|
+
Configuration takes effect at turn boundaries. Generation preparation reads it
|
|
689
|
+
once per request and fixes that turn's prompt, offered tools, model, and request
|
|
690
|
+
options. A configuration change made while the turn runs, such as deactivating a
|
|
691
|
+
tool the model already called, applies at the next preparation; the current
|
|
692
|
+
turn's calls still execute. The retry policy is the exception: generation reads
|
|
693
|
+
it when it classifies an attempt's result, because it governs the next attempt
|
|
694
|
+
(section 8.3).
|
|
635
695
|
|
|
636
696
|
A `Conversation.commit()` is a Session commit bound to that conversation.
|
|
637
697
|
`tx.createTask()` defaults `TaskOptions.conversationId` to the bound conversation.
|
|
@@ -642,31 +702,35 @@ because `Harness extends Session`.
|
|
|
642
702
|
|
|
643
703
|
`fork()` requires a concrete visible parent entry and explicit ownership, then
|
|
644
704
|
applies section 3.7.
|
|
645
|
-
`
|
|
646
|
-
future summary entry.
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
`
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
705
|
+
`compact()` admits a manual compaction task in one commit and returns its ID,
|
|
706
|
+
not its future summary entry. The task is conversation-owned and not background,
|
|
707
|
+
so `Conversation.abort()` cancels it and idle waits include it. It does not take
|
|
708
|
+
run control, so it does not make the conversation busy: the conversation keeps
|
|
709
|
+
working while it summarizes, and its summary is placed through a write
|
|
710
|
+
submission, at once when idle, otherwise at the next boundary (section 8.7). `reset()` durably admits a write submission of a
|
|
711
|
+
`pi.reset` entry (section 8.1) with `head: "self"`, carrying the handoff text as
|
|
712
|
+
a user message when given, and then resolves; while busy, placement follows
|
|
713
|
+
section 6 and may occur later. Observe its placement through the conversation
|
|
714
|
+
watch. An idle wait does not guarantee placement of queued passive writes.
|
|
715
|
+
|
|
716
|
+
`abortTask()` commits `abortRequested`, then signals and joins the active run;
|
|
717
|
+
the scheduler then starts the abort invocation and marks the foreground-owned
|
|
718
|
+
subtree in its next reconcile commit (section 5.4). `Conversation.abort()` withdraws queued
|
|
655
719
|
input submissions, marks non-background tasks selected by ordinary ownership
|
|
656
720
|
traversal, signals them, and resolves only after that scope is ordinarily idle.
|
|
657
721
|
Passive writes and background subtrees survive. Conversation idle means no live
|
|
658
722
|
non-background task selected from that conversation. Harness idle applies the
|
|
659
|
-
same traversal from every ownerless conversation root.
|
|
660
|
-
deadline-blocked work is still live and therefore not idle. Cancelling an idle
|
|
723
|
+
same traversal from every ownerless conversation root. Waiting,
|
|
724
|
+
deadline-blocked, and `completing` work is still live and therefore not idle. Cancelling an idle
|
|
661
725
|
wait aborts only that waiter.
|
|
662
726
|
|
|
663
727
|
Conversation handles are stateless; compare them by `id`. Hosts discover
|
|
664
|
-
conversations through lookups and scans.
|
|
665
|
-
|
|
666
|
-
with run control (Package 17); there is no creation listener.
|
|
728
|
+
conversations through lookups and scans. There is no creation listener and no
|
|
729
|
+
activity view; `inspect()` and the idle waits report live work.
|
|
667
730
|
|
|
668
731
|
`submit()` returns after durable admission, not settlement. An input submission
|
|
669
|
-
creates a user message with the
|
|
732
|
+
creates a user message with the Harness clock's timestamp at placement, which is
|
|
733
|
+
admission when it is placed at once; `whenBusy` defaults to
|
|
670
734
|
`followUp`. A write submission uses the ordered passive path in section 6 and
|
|
671
735
|
never starts generation. `Submission.wait()` settles an input only after its
|
|
672
736
|
answer or terminal failure; it settles a write when the entry is placed or the
|
|
@@ -676,20 +740,24 @@ withdrawal operation. `Harness.submission()` reacquires a submission after
|
|
|
676
740
|
reopen; records remain queryable after settlement.
|
|
677
741
|
|
|
678
742
|
`abortTask()` durably requests cancellation and returns `marked` after the mark
|
|
679
|
-
is committed
|
|
680
|
-
|
|
681
|
-
|
|
743
|
+
is committed and any active run invocation has joined, or after a blocked task
|
|
744
|
+
without live ordinary owned work has been settled as `orphaned` (section 5.4).
|
|
745
|
+
The abort invocation starts once the task's ordinary owned work is no longer
|
|
746
|
+
live; a `completing` task is only marked, and its final record keeps its held
|
|
747
|
+
outcome with `abortRequested: true` (section 5.5). It does not await terminal
|
|
748
|
+
settlement. `waitForTask()` observes
|
|
682
749
|
the terminal receipt. Aborting an already terminal task returns `terminal`; an
|
|
683
750
|
unknown ID rejects. Explicit task abort includes a background task. Cancelling a
|
|
684
751
|
task or idle wait does not abort work.
|
|
685
752
|
|
|
686
753
|
`inspect()` returns live work at one point on the Session line, for recovery
|
|
687
754
|
decisions after open, viewers, and diagnostics. It writes nothing, does not
|
|
688
|
-
enable scheduling, and runs no task code. Each
|
|
689
|
-
|
|
690
|
-
`
|
|
691
|
-
|
|
692
|
-
`
|
|
755
|
+
enable scheduling, and runs no task code. Each live task carries its state
|
|
756
|
+
under the current registry: `running` with an active invocation; `completing`
|
|
757
|
+
for a held outcome; `waiting` with the live tasks it still waits for, the live
|
|
758
|
+
part of its `on`, or, when abort-marked, its live ordinary owned work (section
|
|
759
|
+
5.5); `ready` when the next scheduling pass would reserve it, with `migrates`
|
|
760
|
+
when its definition is newer and has `migrate`; or `blocked` (section 5.4). A migration shows as failed only after the scheduler
|
|
693
761
|
tried it, or when the newer definition has no `migrate`; inspection never runs
|
|
694
762
|
one to find out. Blocked reasons are derived, never stored. Queued and placed
|
|
695
763
|
submissions and the registry's wrapper failures complete the view. Finished
|
|
@@ -965,10 +1033,14 @@ interface Tx {
|
|
|
965
1033
|
token: Entry<D>, conversationId: ConversationId, value: TypedEntryDraft<D>,
|
|
966
1034
|
): Promise<TypedEntry<D>>;
|
|
967
1035
|
createTask<I, S extends { phase: string }, R, H extends object>(
|
|
968
|
-
task: Task<I, S, R, H>, input: I, options
|
|
1036
|
+
task: Task<I, S, R, H>, input: I, options: TaskOptions,
|
|
969
1037
|
): Promise<TaskId<R>>;
|
|
970
1038
|
/** Settle a queued or placed submission; only a placed input can be answered. A settled one stays unchanged. */
|
|
971
1039
|
settleSubmission(id: SubmissionId, settlement: SubmissionSettlement): void;
|
|
1040
|
+
/** Newest visible entry of the conversation that carries a `head`. */
|
|
1041
|
+
latestHeadMarker(conversationId: ConversationId): Promise<(EntryRecord & { readonly head: EntryId }) | undefined>;
|
|
1042
|
+
/** Record a queued submission's placement at `entry`: an input becomes `placed`, a write `done` (section 6). The caller appends the entry and edits `pi.inbox` and `pi.live`. */
|
|
1043
|
+
placeSubmission(id: SubmissionId, entry: EntryId): void;
|
|
972
1044
|
|
|
973
1045
|
doc<T extends JsonObject>(token: SessionDocToken<T>): Promise<Draft<T>>;
|
|
974
1046
|
doc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<Draft<T>>;
|
|
@@ -993,9 +1065,13 @@ For task ownership, the caller supplies only a typed task ID. The Session derive
|
|
|
993
1065
|
the persisted owner conversation from the task's final candidate record.
|
|
994
1066
|
|
|
995
1067
|
An owner task may be committed or staged earlier in the same transaction. Before
|
|
996
|
-
Storage admission, the Session rejects a missing, terminal, or
|
|
997
|
-
|
|
998
|
-
|
|
1068
|
+
Storage admission, the Session rejects a missing, `completing`, terminal, or
|
|
1069
|
+
abort-marked owner of a new task or conversation, judged on the owner's final
|
|
1070
|
+
candidate record in that transaction. A task therefore cannot create owned work
|
|
1071
|
+
in the commit that finishes it. A child task whose explicit `conversationId`
|
|
1072
|
+
differs from its owner's conversation, and a child task with `background: true`,
|
|
1073
|
+
reject the same way. Existing owner edges remain valid when their owners finish
|
|
1074
|
+
afterward. Conversation
|
|
999
1075
|
creation returns an inert record, never an operational handle.
|
|
1000
1076
|
`forkConversation()` additionally validates one visible entry and applies section
|
|
1001
1077
|
3.7. Only public typed `tx.doc()` is get-or-create. Internal fork copying may create
|
|
@@ -1108,10 +1184,10 @@ callback fails with no pending acquisition
|
|
|
1108
1184
|
callback succeeds with no pending acquisition
|
|
1109
1185
|
prepare every open change -> immutable next revision + self-contained Chord Op[]
|
|
1110
1186
|
Session evaluates each required/ordinary document write exactly once
|
|
1111
|
-
prepare every affected loaded conversation mount revision
|
|
1112
1187
|
Storage.commit persists the atomic batch while the Session line remains held
|
|
1113
1188
|
storage succeeds
|
|
1114
1189
|
adopt every prepared change by pointer swap and enqueue immutable revision/ops publication
|
|
1190
|
+
commit listeners, including conversation view mounts (section 9.3), capture it synchronously
|
|
1115
1191
|
release the line; invoke listeners later
|
|
1116
1192
|
storage fails
|
|
1117
1193
|
abort every prepared change, poison Session, and publish nothing
|
|
@@ -1128,8 +1204,8 @@ strict JSON from Storage and enter the tracker without another copy. Chord's
|
|
|
1128
1204
|
every root they receive must come from one of these sources. Migration callbacks
|
|
1129
1205
|
never receive a live tracker revision.
|
|
1130
1206
|
|
|
1131
|
-
Preparation, validation, checkpoint
|
|
1132
|
-
|
|
1207
|
+
Preparation, validation, or checkpoint failure occurs before Storage admission
|
|
1208
|
+
and rolls back normally. The Session performs no
|
|
1133
1209
|
strict-JSON walk of prepared operations or selected bases: roots are checked on
|
|
1134
1210
|
entry and Chord checks every draft placement, so every revision, operation
|
|
1135
1211
|
payload, and base is strict JSON by construction. Tracker branding and `baseRevision` enforce ownership and staleness; the
|
|
@@ -1321,7 +1397,7 @@ await session.commit(async tx => {
|
|
|
1321
1397
|
|
|
1322
1398
|
await tx.appendEntry(conversationId, message); // first table write
|
|
1323
1399
|
delete live.message; // document mutation remains valid
|
|
1324
|
-
await tx.createTask(Follow, {
|
|
1400
|
+
await tx.createTask(Follow, {}, { ownership: { kind: "conversation" } }); // further table writes are fine
|
|
1325
1401
|
}, context);
|
|
1326
1402
|
```
|
|
1327
1403
|
|
|
@@ -1359,27 +1435,42 @@ type TaskOutcome<R> =
|
|
|
1359
1435
|
| { readonly status: "orphaned"; readonly reason: string }
|
|
1360
1436
|
| { readonly status: "faulted"; readonly error: TaskOutcomeError };
|
|
1361
1437
|
|
|
1438
|
+
type JoinPolicy = "failFast" | "allSettled";
|
|
1439
|
+
|
|
1362
1440
|
type TaskState<S, R> =
|
|
1363
1441
|
| { readonly status: "pending"; readonly checkpoint: S }
|
|
1364
1442
|
| { readonly status: "running"; readonly checkpoint: S }
|
|
1443
|
+
/** Parked without an invocation until every task in `on` is terminal; then resumes at `checkpoint` (section 5.5). */
|
|
1444
|
+
| {
|
|
1445
|
+
readonly status: "waiting";
|
|
1446
|
+
readonly checkpoint: S;
|
|
1447
|
+
readonly on: readonly TaskId[];
|
|
1448
|
+
readonly policy: JoinPolicy;
|
|
1449
|
+
}
|
|
1450
|
+
/** Outcome decided; terminal once no ordinary owned work below is live (section 5.5). Runs no more code. */
|
|
1451
|
+
| { readonly status: "completing"; readonly outcome: TaskOutcome<R> }
|
|
1365
1452
|
| { readonly status: "terminal"; readonly outcome: TaskOutcome<R> };
|
|
1366
1453
|
|
|
1454
|
+
/** Who owns a task: its conversation (a top-level task) or another task of the same conversation. */
|
|
1455
|
+
type TaskOwnership = { readonly kind: "conversation" } | { readonly kind: "task"; readonly taskId: TaskId };
|
|
1456
|
+
|
|
1367
1457
|
type TaskRecord<I, S, R> = {
|
|
1368
1458
|
readonly id: TaskId<R>;
|
|
1369
1459
|
readonly conversationId: ConversationId;
|
|
1370
1460
|
readonly kind: string;
|
|
1371
1461
|
readonly version: number;
|
|
1372
1462
|
readonly input: I;
|
|
1373
|
-
|
|
1463
|
+
/** Owning task; absent for a task its conversation owns. Immutable. */
|
|
1464
|
+
readonly owner?: TaskId;
|
|
1374
1465
|
readonly background: boolean;
|
|
1375
1466
|
readonly abortRequested: boolean;
|
|
1376
1467
|
} & (
|
|
1377
1468
|
| {
|
|
1378
|
-
readonly state: Extract<TaskState<S, R>, { status: "pending" | "running" }>;
|
|
1469
|
+
readonly state: Extract<TaskState<S, R>, { status: "pending" | "running" | "waiting" }>;
|
|
1379
1470
|
readonly memos?: Readonly<Record<string, JsonValue>>;
|
|
1380
1471
|
}
|
|
1381
1472
|
| {
|
|
1382
|
-
readonly state: Extract<TaskState<S, R>, { status: "terminal" }>;
|
|
1473
|
+
readonly state: Extract<TaskState<S, R>, { status: "completing" | "terminal" }>;
|
|
1383
1474
|
readonly memos?: never;
|
|
1384
1475
|
}
|
|
1385
1476
|
);
|
|
@@ -1388,11 +1479,14 @@ type RunningTask<I, S, R> = TaskRecord<I, S, R> & {
|
|
|
1388
1479
|
readonly state: Extract<TaskState<S, R>, { status: "running" }>;
|
|
1389
1480
|
};
|
|
1390
1481
|
|
|
1391
|
-
/**
|
|
1392
|
-
|
|
1482
|
+
/**
|
|
1483
|
+
* State a task commits for itself: a replacement checkpoint, a wait, or its outcome. A returned `terminal` state
|
|
1484
|
+
* becomes `completing` while ordinary owned work below is live (section 5.5).
|
|
1485
|
+
*/
|
|
1486
|
+
type NextTaskState<S, R> = Extract<TaskState<S, R>, { status: "running" | "waiting" | "terminal" }>;
|
|
1393
1487
|
|
|
1394
1488
|
interface HookRunner<H extends object> {
|
|
1395
|
-
each<K extends keyof H>(name: K, invoke: (handler: H[K]) => void | Promise<void>): Promise<void>;
|
|
1489
|
+
each<K extends keyof H>(name: K, invoke: (handler: NonNullable<H[K]>) => void | Promise<void>): Promise<void>;
|
|
1396
1490
|
}
|
|
1397
1491
|
|
|
1398
1492
|
type PhaseHandler<I, P, S, R, H extends object> = (
|
|
@@ -1409,6 +1503,8 @@ interface TaskRuntime<I, S, R, H extends object> extends DocumentObserver, Docum
|
|
|
1409
1503
|
readonly registry: RegistrySnapshot<ToolRegistration>;
|
|
1410
1504
|
readonly models: Models;
|
|
1411
1505
|
readonly hooks: HookRunner<H>;
|
|
1506
|
+
/** `HarnessOptions.env`; the tool task passes it to tools as `api.env`. */
|
|
1507
|
+
readonly env: ExecutionEnv | undefined;
|
|
1412
1508
|
|
|
1413
1509
|
commit(
|
|
1414
1510
|
change: (
|
|
@@ -1421,6 +1517,15 @@ interface TaskRuntime<I, S, R, H extends object> extends DocumentObserver, Docum
|
|
|
1421
1517
|
memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
|
|
1422
1518
|
memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
|
|
1423
1519
|
conversation(id: ConversationId, context: Context): Promise<ConversationHandle | undefined>;
|
|
1520
|
+
/** Committed task record. */
|
|
1521
|
+
getTask<T>(id: TaskId<T>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, T> | undefined>;
|
|
1522
|
+
/** Terminal receipt; rejects when the invocation ends. */
|
|
1523
|
+
waitForTask<T>(id: TaskId<T>, context: Context): Promise<SettledTask<T>>;
|
|
1524
|
+
/** Outcomes of terminal tasks, in order; rejects when one is not terminal. Used after a wait (section 5.5). */
|
|
1525
|
+
outcomes<T>(ids: readonly TaskId<T>[], context: Context): Promise<TaskOutcome<T>[]>;
|
|
1526
|
+
/** Committed entry visible from the task's conversation. */
|
|
1527
|
+
entry(id: EntryId, context: Context): Promise<EntryRecord | undefined>;
|
|
1528
|
+
entry<D extends JsonValue>(token: Entry<D>, id: EntryId, context: Context): Promise<TypedEntry<D> | undefined>;
|
|
1424
1529
|
/** Committed raw active transcript and model context, optionally cut off at `at`. */
|
|
1425
1530
|
context(conversationId: ConversationId, context: Context, at?: EntryId): Promise<ContextView>;
|
|
1426
1531
|
/** The Harness clock. */
|
|
@@ -1450,8 +1555,11 @@ interface Task<I, S extends { phase: string }, R, H extends object> {
|
|
|
1450
1555
|
}
|
|
1451
1556
|
|
|
1452
1557
|
type TaskOptions = {
|
|
1558
|
+
/** Required: a task always names its owner (section 5.5). */
|
|
1559
|
+
readonly ownership: TaskOwnership;
|
|
1560
|
+
/** Default: the owner task's conversation, or the transaction's bound conversation. */
|
|
1453
1561
|
readonly conversationId?: ConversationId;
|
|
1454
|
-
|
|
1562
|
+
/** Conversation-owned tasks only: excluded from conversation abort and idle, and from cascades. */
|
|
1455
1563
|
readonly background?: boolean;
|
|
1456
1564
|
};
|
|
1457
1565
|
|
|
@@ -1477,9 +1585,12 @@ entry it appends records the task as `byTaskId`. When the
|
|
|
1477
1585
|
callback returns a state, the runtime replaces the task's state in the same
|
|
1478
1586
|
commit, so the checkpoint or outcome is atomic with the callback's entries,
|
|
1479
1587
|
documents, and child tasks and is type-checked against the task's checkpoint and
|
|
1480
|
-
result types. Returning nothing leaves the state unchanged. A terminal
|
|
1481
|
-
drops the memos. `
|
|
1482
|
-
|
|
1588
|
+
result types. Returning nothing leaves the state unchanged. A terminal or
|
|
1589
|
+
completing state drops the memos. A `waiting` state ends the invocation even
|
|
1590
|
+
when its checkpoint is unchanged; it rejects when `on` names a missing task, the
|
|
1591
|
+
task itself, or a task on its owner chain, which could never finish first, and
|
|
1592
|
+
an empty `on` resumes at the next scheduling pass. `pending` is never returned;
|
|
1593
|
+
only reconciliation and handover write it.
|
|
1483
1594
|
|
|
1484
1595
|
`Tx` has no task replacement operation. A task changes only its own state,
|
|
1485
1596
|
through its runtime. The scheduler owns reservation, reconciliation, handover,
|
|
@@ -1509,11 +1620,14 @@ record. `sleep(until)` compares against the Harness `now` clock and rejects when
|
|
|
1509
1620
|
the invocation is signalled or its context is cancelled. Watches acquired through
|
|
1510
1621
|
the runtime stop when the invocation ends. `snapshot()`/`snapshotAsOf()` read
|
|
1511
1622
|
committed documents, for example to supply `PromptInput.read` (section 7.4).
|
|
1623
|
+
`getTask()` and `entry()` read committed records with one lookup each; `waitForTask()` waits for a terminal receipt,
|
|
1624
|
+
for example a child task created by a tool.
|
|
1512
1625
|
`context()` captures its bounds on the Session line and derives the view from
|
|
1513
1626
|
immutable entries off the line, like `Conversation.context()`. Like every runtime
|
|
1514
1627
|
operation, these reject after the invocation ends.
|
|
1515
1628
|
|
|
1516
|
-
Reservation durably changes `pending
|
|
1629
|
+
Reservation durably changes `pending`, or `waiting` once it may resume, to
|
|
1630
|
+
`running`. One invocation runs phase
|
|
1517
1631
|
handlers in sequence; checkpoint commits retain `running`. Before every phase,
|
|
1518
1632
|
including the first, the scheduler runs one step callback on the Session line.
|
|
1519
1633
|
It reads the committed task and synchronously applies the first matching rule
|
|
@@ -1525,10 +1639,11 @@ invocation queued earlier therefore either lands before the step and counts, or
|
|
|
1525
1639
|
reaches the line after it and rejects. Before the first phase only rules 1–3
|
|
1526
1640
|
apply:
|
|
1527
1641
|
|
|
1528
|
-
1. Terminal
|
|
1642
|
+
1. Terminal, `completing`, or `waiting`: stop.
|
|
1529
1643
|
2. Session closing: stop; preserve the checkpoint and any abort mark for reopen.
|
|
1530
|
-
3. Run mode with a durable abort mark: end and join the run invocation
|
|
1531
|
-
|
|
1644
|
+
3. Run mode with a durable abort mark: end and join the run invocation; a fresh
|
|
1645
|
+
abort invocation starts once the task's ordinary owned work is no longer
|
|
1646
|
+
live (section 5.5).
|
|
1532
1647
|
4. Uncaught error: write terminal `faulted`.
|
|
1533
1648
|
5. Checkpoint changed, including progress within the same phase: refresh the
|
|
1534
1649
|
registry snapshot and either hand over (section 5.4) or invoke the phase
|
|
@@ -1543,10 +1658,11 @@ invocation still ends; the task stays `running` and the next reservation runs it
|
|
|
1543
1658
|
again.
|
|
1544
1659
|
|
|
1545
1660
|
On open, running-task reconciliation changes surviving `running` tasks back to
|
|
1546
|
-
`pending`, preserving their checkpoint and abort mark
|
|
1547
|
-
|
|
1661
|
+
`pending`, preserving their checkpoint and abort mark; `waiting` tasks stay
|
|
1662
|
+
waiting, and `completing` and `waiting` tasks are re-evaluated (section 5.5). Task migration runs at
|
|
1663
|
+
reservation, atomically with `pending` or `waiting` -> `running`. One callback handles every
|
|
1548
1664
|
supported older version. A task whose definition is missing, older than the
|
|
1549
|
-
stored version, or fails migration stays `pending` and blocked until a fitting
|
|
1665
|
+
stored version, or fails migration stays `pending` or `waiting` and blocked until a fitting
|
|
1550
1666
|
definition is registered or the task is aborted (section 5.4).
|
|
1551
1667
|
`close()` marks the runtime closing, seals admission and reservation, signals
|
|
1552
1668
|
invocations, and stops watches. Outside the Session line it settles admitted
|
|
@@ -1576,7 +1692,7 @@ envelope. Candidate insertion and reading the winner are one Session commit, so
|
|
|
1576
1692
|
concurrent candidates return the same durable winner. Memos survive checkpoints
|
|
1577
1693
|
and disappear in the terminal replacement. Bulk progress belongs in a document.
|
|
1578
1694
|
|
|
1579
|
-
### 5.3 Terminal tasks
|
|
1695
|
+
### 5.3 Terminal tasks
|
|
1580
1696
|
|
|
1581
1697
|
The terminal task record is the durable result receipt. Its result may directly
|
|
1582
1698
|
contain a small value or reference an entry:
|
|
@@ -1592,10 +1708,14 @@ A terminal transition atomically:
|
|
|
1592
1708
|
3. Retires all documents scoped to that task.
|
|
1593
1709
|
4. Resolves any submissions settled by the task.
|
|
1594
1710
|
|
|
1711
|
+
A task whose ordinary owned work is live splits this at a `completing` hold
|
|
1712
|
+
(section 5.5, rule 4): a task-written outcome's own writes land at hold, and
|
|
1713
|
+
items 1 and 3 and its waiters follow in the final commit.
|
|
1714
|
+
|
|
1595
1715
|
The execution checkpoint and memos disappear from the terminal representation.
|
|
1596
|
-
Terminal records remain queryable for
|
|
1597
|
-
|
|
1598
|
-
|
|
1716
|
+
Terminal records remain queryable for waits, waiters, inspection, and reopen.
|
|
1717
|
+
There are no free-standing task dependencies: ordering comes from a task waiting
|
|
1718
|
+
on other tasks (section 5.5). An abort mark lets pending work reach its abort handler,
|
|
1599
1719
|
or its `orphaned` settlement when its definition is unavailable.
|
|
1600
1720
|
|
|
1601
1721
|
### 5.4 Scheduler, abort, and ownership
|
|
@@ -1609,47 +1729,75 @@ Abort protocol:
|
|
|
1609
1729
|
```text
|
|
1610
1730
|
commit abortRequested
|
|
1611
1731
|
signal and join active run invocation
|
|
1732
|
+
wait until ordinary owned work is no longer live (section 5.5)
|
|
1612
1733
|
start a fresh abort invocation
|
|
1613
1734
|
abort handler commits terminal outcome
|
|
1614
1735
|
```
|
|
1615
1736
|
|
|
1616
1737
|
A run invocation may not commit after its durable abort mark appears. Every
|
|
1617
1738
|
runtime operation rejects after its owning invocation ends, even while the
|
|
1618
|
-
Session remains open
|
|
1739
|
+
Session remains open, and the invocation's signal and handler context abort when
|
|
1740
|
+
it ends for any reason. An invocation ends only after its last handler returned,
|
|
1741
|
+
so this cancels only detached leftovers, such as an unawaited `waitForTask()` or
|
|
1742
|
+
a fetch started with the handler's context; they could no longer write anything. Returning from one phase handler does not end an invocation
|
|
1619
1743
|
that continues into another phase. Invocation mode is volatile and derived from
|
|
1620
1744
|
the durable mark on reopen. Cancelling one caller's `Context` only cancels
|
|
1621
1745
|
that call or wait; it does not durably abort shared work unless the invoked API
|
|
1622
1746
|
commits an abort mark.
|
|
1623
1747
|
|
|
1624
|
-
A task may create owned conversations
|
|
1625
|
-
are the units of live work counted by
|
|
1626
|
-
are irrelevant to ownership traversal.
|
|
1748
|
+
A task may create owned conversations and owned tasks (section 5.5).
|
|
1749
|
+
Conversations are durable scopes; tasks are the units of live work counted by
|
|
1750
|
+
idle and marked by abort. History parents are irrelevant to ownership traversal.
|
|
1627
1751
|
|
|
1628
1752
|
Ordinary traversal starts at an explicitly addressed conversation, visits its
|
|
1629
|
-
tasks,
|
|
1630
|
-
|
|
1631
|
-
|
|
1753
|
+
conversation-owned tasks, follows the tasks each non-background task owns, and
|
|
1754
|
+
follows the conversations each non-background task owns. It follows owner edges after the
|
|
1755
|
+
owner becomes terminal, but a background task is a boundary: ordinary traversal
|
|
1756
|
+
skips that task and its complete owned subtree.
|
|
1632
1757
|
Direct conversation operations start inside that conversation regardless of its
|
|
1633
1758
|
owner. Directly aborting a live background task includes that task and follows
|
|
1634
|
-
its ordinary owned subtree; nested background owners remain boundaries.
|
|
1635
|
-
|
|
1636
|
-
|
|
1759
|
+
its ordinary owned subtree; nested background owners remain boundaries.
|
|
1760
|
+
`close()` stops every invocation, foreground and background, without writing an
|
|
1761
|
+
outcome, so the work resumes on reopen. A host that must durably cancel
|
|
1762
|
+
everything aborts each live task that `inspect()` lists.
|
|
1637
1763
|
|
|
1638
1764
|
`Conversation.abort()` withdraws queued inputs and marks live non-background
|
|
1639
|
-
tasks selected by ordinary traversal. `
|
|
1765
|
+
tasks selected by ordinary traversal. With `{ background: true }` it crosses
|
|
1766
|
+
background boundaries: it marks every live task that traversal ignoring the
|
|
1767
|
+
background flag reaches when the operation is admitted and withdraws the queued
|
|
1768
|
+
inputs of every conversation it reaches. It then waits until every task it
|
|
1769
|
+
marked is terminal and the conversation is ordinarily idle; background work
|
|
1770
|
+
created afterwards is neither marked nor awaited. `Conversation.waitForIdle()` waits until
|
|
1640
1771
|
that traversal contains no live non-background task. Harness idle performs the
|
|
1641
1772
|
same traversal from every ownerless conversation root rather than globally
|
|
1642
1773
|
counting tasks, so ordinary work below a background owner does not block it.
|
|
1643
1774
|
Explicit `waitForTask()` waits for its referenced task regardless of the task's
|
|
1644
1775
|
background flag.
|
|
1645
1776
|
|
|
1646
|
-
An abort mark
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1777
|
+
An abort mark cascades idempotently to foreground-owned work. The durable
|
|
1778
|
+
cancellation intent is a live owner's own record: its abort mark, or a held
|
|
1779
|
+
`completing` outcome other than `completed` (section 5.5). A terminal owner never
|
|
1780
|
+
cascades: it became terminal only after its ordinary owned work drained, so its
|
|
1781
|
+
intent is already applied, and work later started in a conversation it owned,
|
|
1782
|
+
for example a user interrogating a finished subagent, runs normally and is
|
|
1783
|
+
reached by ordinary traversal from above. Queued inputs in its owned
|
|
1784
|
+
conversations stay queued, as after a failed run (section 6). The scheduler derives the marks in a
|
|
1785
|
+
later reconcile commit, and again at open, so a crash in between loses nothing;
|
|
1786
|
+
work already queued on the Session line may make one more commit first. Every
|
|
1787
|
+
live non-background task whose owner chain, walking up through owning tasks and
|
|
1788
|
+
owning conversations, reaches a cancelled live owner, without first crossing a
|
|
1789
|
+
background task that has no cancellation intent of its own, gets an abort mark, including work
|
|
1790
|
+
created there after the cascade. Deriving the marks in their own commit
|
|
1791
|
+
keeps them out of the owner's commit, which may already have written the tasks
|
|
1792
|
+
involved. Every conversation the cascade reaches is treated like
|
|
1793
|
+
`Conversation.abort()`: its queued input submissions become `unanswered` with
|
|
1794
|
+
reason `aborted` and leave its inbox, while queued writes stay. The cascade starts
|
|
1795
|
+
below the cancelled task, so that task's own conversation keeps its queue.
|
|
1796
|
+
Held outcomes `failed`, `faulted`, `orphaned`, and `aborted` record the same
|
|
1797
|
+
durable cancellation intent as an abort mark; `completed` does not. Conversation
|
|
1798
|
+
records and owner edges are never retired with the task. Active invocations are
|
|
1799
|
+
signalled after commit. A terminal receipt guarantees that the task's ordinary
|
|
1800
|
+
owned work drained; background work below it may continue.
|
|
1653
1801
|
|
|
1654
1802
|
Every run invocation start resolves the task's definition by `TaskRecord.kind`
|
|
1655
1803
|
from the scheduler's current registry snapshot:
|
|
@@ -1683,25 +1831,39 @@ missing or cannot take the task. The Harness therefore settles it as terminal
|
|
|
1683
1831
|
ran and decided the outcome, while `orphaned` means no task code ran, so external
|
|
1684
1832
|
effects the task started may remain uncleaned. The orphaned `reason` is the
|
|
1685
1833
|
blocked reason (`missing_task`, `task_too_old`, or `migration_failed`). When
|
|
1686
|
-
`abortTask()` finds no active invocation
|
|
1687
|
-
cannot take the task, the marking commit settles it
|
|
1688
|
-
scheduler settles it when it would reserve the abort
|
|
1834
|
+
`abortTask()` finds no active invocation, no live ordinary owned work, and a
|
|
1835
|
+
current snapshot that cannot take the task, the marking commit settles it
|
|
1836
|
+
directly; otherwise the scheduler settles it when it would reserve the abort
|
|
1837
|
+
invocation, which is after the owned work drained (section 5.5), so an orphaned
|
|
1838
|
+
outcome never holds. Only an abort (direct, by
|
|
1689
1839
|
conversation, or by cascade) orphans a task; a missing definition alone never
|
|
1690
1840
|
does. The orphaning commit performs the cleanup the task's code cannot: affected
|
|
1691
1841
|
input submissions become unanswered with the reason, any matching active run
|
|
1692
|
-
control is cleared, and task-scoped documents retire.
|
|
1693
|
-
|
|
1842
|
+
control is cleared, and task-scoped documents retire. The terminal task record
|
|
1843
|
+
and unanswered submissions carry the reason; the only transcript entry written
|
|
1844
|
+
is the conversion of a committed generation partial (below).
|
|
1694
1845
|
Faulting a run task performs the same control/submission cleanup with a
|
|
1695
|
-
`faulted` outcome
|
|
1846
|
+
`faulted` outcome; a fault while ordinary owned work is live holds, and its
|
|
1847
|
+
cleanup runs in the final commit (section 5.5, rule 4).
|
|
1696
1848
|
|
|
1697
1849
|
The scheduler knows nothing about runs or task kinds. The Harness, which owns
|
|
1698
1850
|
submissions, run control, and the built-in tasks, gives it one hook that the
|
|
1699
|
-
scheduler calls in the
|
|
1700
|
-
|
|
1701
|
-
run kind, so it never creates `pi.live` elsewhere. It settles the run when
|
|
1702
|
-
`pi.live.run` names the task (section 8):
|
|
1703
|
-
|
|
1704
|
-
|
|
1851
|
+
scheduler calls in the commit that makes an outcome it wrote itself (`faulted`
|
|
1852
|
+
and `orphaned`) terminal, which is the final commit after a hold. The hook ignores tasks whose kind is not a built-in
|
|
1853
|
+
run, tool, or compaction kind, so it never creates `pi.live` elsewhere. It settles the run when
|
|
1854
|
+
`pi.live.run` names the task (section 8): a committed generation partial becomes
|
|
1855
|
+
an aborted `pi.assistant` entry, exactly as the generation abort handler converts
|
|
1856
|
+
it, so the transcript keeps what the model produced and `pi.usage` counts its
|
|
1857
|
+
spend (the scheduler's commit has no task scope, so the entry has no
|
|
1858
|
+
`byTaskId`); its inputs become `unanswered` with reason `faulted` (detail: the error
|
|
1859
|
+
message) or the blocked reason; and `run`, `generation`, and `tools` are
|
|
1860
|
+
removed. Faults come from task bugs or malformed provider data, such as a
|
|
1861
|
+
non-JSON value in a response, or a commit the Storage rejected without effect
|
|
1862
|
+
(`StorageRejected`). An uncertain storage failure poisons the Session and writes
|
|
1863
|
+
no outcome. For a `pi.tool` task it marks the task's tool slot `done`
|
|
1864
|
+
without an entry; the run continues, and context derivation synthesizes the
|
|
1865
|
+
missing result (section 2.1). For a `pi.compaction` task it removes the task's
|
|
1866
|
+
compaction status (section 8.7). Outcomes a task commits for itself do their own settlement.
|
|
1705
1867
|
|
|
1706
1868
|
At every normal phase boundary (section 5.1, rule 5), the step refreshes the
|
|
1707
1869
|
invocation's registry snapshot. If the task definition resolved by name is a different object
|
|
@@ -1715,23 +1877,133 @@ definition, reports once through `onReport` per resolved definition, and
|
|
|
1715
1877
|
reconsiders at its next boundary. A handler that
|
|
1716
1878
|
never settles never hands over.
|
|
1717
1879
|
|
|
1880
|
+
### 5.5 Structured concurrency
|
|
1881
|
+
|
|
1882
|
+
Tasks and conversations form one ownership tree. Every task names its owner at
|
|
1883
|
+
creation, `tx.createTask(task, input, { ownership })` with a required
|
|
1884
|
+
`TaskOwnership`: its conversation (`{ kind: "conversation" }`, a top-level task)
|
|
1885
|
+
or a task (`{ kind: "task", taskId }`, a child task). Every conversation is
|
|
1886
|
+
ownerless or owned by a task (section 2.2). A child task always lives in its
|
|
1887
|
+
owner's conversation; only owned conversations cross conversation boundaries.
|
|
1888
|
+
Owner edges are immutable.
|
|
1889
|
+
|
|
1890
|
+
The **ordinary owned work** of a task `T` is every task that ordinary traversal
|
|
1891
|
+
reaches below `T`: the tasks `T` owns, the tasks in the conversations `T` owns,
|
|
1892
|
+
and, transitively, the same for each of those that is not background. A
|
|
1893
|
+
background task and everything below it are excluded. A conversation-owned task
|
|
1894
|
+
may be background; a child task may not.
|
|
1895
|
+
|
|
1896
|
+
Two rules follow from the tree.
|
|
1897
|
+
|
|
1898
|
+
**Abort flows down** (section 5.4). A live owner's cancellation intent (its abort
|
|
1899
|
+
mark, or a held non-`completed` outcome) marks its ordinary owned work. Background
|
|
1900
|
+
tasks are boundaries unless aborted directly or by `Conversation.abort(context,
|
|
1901
|
+
{ background: true })`.
|
|
1902
|
+
|
|
1903
|
+
**Owned work does not outlive its owner's finish.** A task finishes only after
|
|
1904
|
+
its ordinary owned work drained:
|
|
1905
|
+
|
|
1906
|
+
1. A terminal state a task commits, or a terminal outcome the scheduler writes
|
|
1907
|
+
(`faulted`, `orphaned`), while the task's ordinary owned work is live is
|
|
1908
|
+
stored as `{ status: "completing", outcome }` instead. The scheduler writes
|
|
1909
|
+
the final `terminal` record in a later commit once no ordinary owned work is
|
|
1910
|
+
live, evaluated after every commit, including work created after the hold.
|
|
1911
|
+
Whether work is live is judged on the commit's candidate records, so work the
|
|
1912
|
+
finishing commit itself creates, for example a task in a conversation the
|
|
1913
|
+
task owns, holds it. At open it re-evaluates every `completing` task.
|
|
1914
|
+
2. A held outcome is final: no phase, runtime commit, or abort handler runs
|
|
1915
|
+
again, no definition is needed, the task is never reserved or migrated, and
|
|
1916
|
+
`abortTask()` on it only marks it and returns `marked`, which cancels the
|
|
1917
|
+
work below. The final record keeps the held outcome and the mark.
|
|
1918
|
+
3. A held non-`completed` outcome is cancellation intent, so the work below is
|
|
1919
|
+
aborted, drains, and then the task becomes terminal. A held `completed`
|
|
1920
|
+
outcome waits for the work to finish normally.
|
|
1921
|
+
4. Writes split at the hold. A task-written terminal commit lands all of its
|
|
1922
|
+
other writes, such as a tool's result entry and slot, at hold. Only the
|
|
1923
|
+
record's terminal state, task-document retirement, and task waiters are
|
|
1924
|
+
deferred to the final commit. A scheduler-written outcome writes only the
|
|
1925
|
+
record at hold; its Harness cleanup (section 5.4) runs in the final commit,
|
|
1926
|
+
so a faulted run task keeps `pi.live.run` until its tools drained.
|
|
1927
|
+
5. Waiters, idle waits, inspection, and ordinary traversal see a `completing`
|
|
1928
|
+
task as live until its final commit.
|
|
1929
|
+
|
|
1930
|
+
New owned work needs a live owner: `createTask` with task ownership and
|
|
1931
|
+
`createConversation` with task ownership reject an owner that is `completing`,
|
|
1932
|
+
`terminal`, or abort-marked in the commit's final candidate (section 3.3).
|
|
1933
|
+
Conversations stay usable after their owner finished: new runs
|
|
1934
|
+
started there, for example to interrogate a finished subagent, are ordinary work
|
|
1935
|
+
of that conversation, reached by traversal from above, and not part of any
|
|
1936
|
+
finish.
|
|
1937
|
+
|
|
1938
|
+
**Waiting.** A phase may commit `{ status: "waiting", checkpoint, on, policy }`.
|
|
1939
|
+
The task stops without an invocation and resumes at `checkpoint` once every task
|
|
1940
|
+
in `on` is terminal. `on` may name any tasks, including already terminal ones;
|
|
1941
|
+
tasks the waiting task does not own, whose `owner` is not the waiting task,
|
|
1942
|
+
require `policy: "allSettled"`. `on` may not name a missing task, the task
|
|
1943
|
+
itself, or a task on its owner chain (section 5.1). With `failFast`, the first
|
|
1944
|
+
task in `on` that holds or ends with a non-`completed` outcome gives every other
|
|
1945
|
+
live task in `on` an abort mark, in the scheduler's next reconcile commit. The waiting task itself is not marked:
|
|
1946
|
+
it resumes once all of `on` is terminal and reads their outcomes with
|
|
1947
|
+
`runtime.outcomes()`. A task may create children and keep running phases before
|
|
1948
|
+
it waits, and may wait on any subset of its children in sequence.
|
|
1949
|
+
|
|
1950
|
+
**Abort order is bottom-up.** An abort invocation of a task starts only once its
|
|
1951
|
+
ordinary owned work is no longer live, so an abort handler sees final outcomes
|
|
1952
|
+
below it. An abort mark on a waiting task lets it leave the wait early for its
|
|
1953
|
+
abort handler under this same rule; tasks in its `on` that it does not own are
|
|
1954
|
+
not awaited. Ordering is judged on committed records: code a child runs after
|
|
1955
|
+
its terminal commit is not ordered. An abort handler may not return `waiting`
|
|
1956
|
+
and cannot create owned children, because its task is abort-marked (section
|
|
1957
|
+
3.3): it compensates inline, or creates background conversation-owned tasks,
|
|
1958
|
+
which no cascade reaches, and waits for them with `runtime.waitForTask()`,
|
|
1959
|
+
which holds its invocation.
|
|
1960
|
+
|
|
1961
|
+
The cascade of the example `Checkout` task, which waits `failFast` on four
|
|
1962
|
+
`Payment` children: an expired card fails its payment; the three live payments
|
|
1963
|
+
get abort marks and refund in their own abort handlers; once all four are
|
|
1964
|
+
terminal, `Checkout` resumes, reads `[aborted, failed, aborted, aborted]`
|
|
1965
|
+
through `outcomes()`, and decides its own outcome. `abortTask(Checkout)` instead
|
|
1966
|
+
marks `Checkout`, the cascade marks the live payments, their abort handlers run
|
|
1967
|
+
first, then `Checkout.abort` runs and sees their final outcomes.
|
|
1968
|
+
|
|
1718
1969
|
## 6. Submissions and inbox
|
|
1719
1970
|
|
|
1720
1971
|
Submission records back awaitable host objects. Admission is Harness-internal;
|
|
1721
|
-
|
|
1722
|
-
|
|
1972
|
+
boundaries place queued submissions with `tx.placeSubmission()`, and run tasks
|
|
1973
|
+
settle the inputs they answer with `tx.settleSubmission()`. Queued submissions
|
|
1974
|
+
wait in the built-in inbox document, an ordered list of tagged items:
|
|
1723
1975
|
|
|
1724
1976
|
```ts
|
|
1725
1977
|
type InboxItem =
|
|
1726
|
-
| { readonly id: SubmissionId; readonly mode: "steer" | "followUp"; readonly
|
|
1727
|
-
|
|
1978
|
+
| { readonly id: SubmissionId; readonly mode: "steer" | "followUp"; readonly content: UserInput }
|
|
1979
|
+
/** `entry` is the write's `EntryDraft`, stored as plain JSON. */
|
|
1980
|
+
| { readonly id: SubmissionId; readonly mode: "write"; readonly entry: JsonObject };
|
|
1981
|
+
|
|
1982
|
+
type InboxState = { items: InboxItem[] };
|
|
1728
1983
|
```
|
|
1729
1984
|
|
|
1985
|
+
| field | value |
|
|
1986
|
+
|---|---|
|
|
1987
|
+
| kind | `pi.inbox` |
|
|
1988
|
+
| version | `1` |
|
|
1989
|
+
| scope/history/fork | conversation, `latest`, `initial` |
|
|
1990
|
+
| `initial()` | `{ items: [] }` |
|
|
1991
|
+
| checkpoint | complete base whenever `items` is empty |
|
|
1992
|
+
| view mount | `docs["pi.inbox"]` |
|
|
1993
|
+
| created | with every Harness conversation (section 2.2) |
|
|
1994
|
+
|
|
1995
|
+
Items are in ID order. A queued input stores its content; its `pi.user` entry
|
|
1996
|
+
gets the Harness clock's timestamp at placement. Queued submissions belong to
|
|
1997
|
+
their conversation, so a fork starts with an empty inbox.
|
|
1998
|
+
|
|
1730
1999
|
Run control lives in the built-in live document `pi.live` (section 8). Its
|
|
1731
2000
|
optional `run` value names the task currently responsible for the run and its
|
|
1732
2001
|
placed input-submission IDs. `run !== undefined` defines `busy`; get-or-create
|
|
1733
2002
|
of the idle document does not. The value remains while generation, tools, and
|
|
1734
|
-
|
|
2003
|
+
tools hand work to one another: `taskId` names the generation that settles the
|
|
2004
|
+
inputs, including while its tool round runs, then the next
|
|
2005
|
+
generation. Tool tasks never own the run; the current round's tool tasks are
|
|
2006
|
+
listed in `pi.live.tools`. The ID list is mutable state because a
|
|
1735
2007
|
boundary adds placed steering inputs to an active run; every terminal path
|
|
1736
2008
|
settles exactly the listed inputs.
|
|
1737
2009
|
|
|
@@ -1739,43 +2011,85 @@ Admission and terminal transitions:
|
|
|
1739
2011
|
|
|
1740
2012
|
| action | submission state | other writes |
|
|
1741
2013
|
|---|---|---|
|
|
1742
|
-
| idle
|
|
1743
|
-
|
|
|
1744
|
-
|
|
|
1745
|
-
|
|
|
1746
|
-
| boundary places user item | `placed`, with entry | add ID to current/successor run |
|
|
2014
|
+
| input, idle with empty inbox | `placed`, with user entry | create run and generation |
|
|
2015
|
+
| write, idle with empty inbox | `done`, with entry | append entry; no run |
|
|
2016
|
+
| input or write, busy or non-empty inbox | `queued` | append inbox item |
|
|
2017
|
+
| boundary places user item | `placed`, with entry | add ID to current or successor run |
|
|
1747
2018
|
| boundary places write | `done`, with entry | append entry |
|
|
1748
|
-
| run answers | input `done`, with required answer entry |
|
|
1749
|
-
| run fails or aborts | input `unanswered`, with reason |
|
|
2019
|
+
| run answers | input `done`, with required answer entry | remove `run`, or hand it to a successor |
|
|
2020
|
+
| run fails or its task aborts | input `unanswered`, with reason | remove `run`; inbox unchanged |
|
|
1750
2021
|
| withdraw queued item | `unanswered`, reason `aborted` | remove inbox item |
|
|
1751
|
-
| stale
|
|
2022
|
+
| stale head write | `unanswered`, reason `stale` | remove inbox item |
|
|
1752
2023
|
|
|
1753
2024
|
`requestId` deduplicates within one conversation before any write; reusing one
|
|
1754
2025
|
for the other submission type rejects. A busy input with `whenBusy: "reject"`
|
|
1755
|
-
writes no record and reports `ConversationBusy`.
|
|
1756
|
-
|
|
1757
|
-
|
|
2026
|
+
writes no record and reports `ConversationBusy`. An idle input queues with mode
|
|
2027
|
+
`steer` when `whenBusy` is `steer` and `followUp` otherwise.
|
|
2028
|
+
|
|
2029
|
+
An idle conversation with a non-empty inbox, for example after a failed run,
|
|
2030
|
+
queues every new submission behind the waiting items and runs a final boundary
|
|
2031
|
+
in the same commit. Order is preserved: a follow-up queued before the failure is
|
|
2032
|
+
selected before the new input.
|
|
2033
|
+
|
|
2034
|
+
A `Submission` waits until `done` or `unanswered`; abort withdraws only a still-
|
|
1758
2035
|
queued submission, reports `already_placed` for a placed input, and reports
|
|
1759
|
-
`settled` for any terminal submission. Conversation abort withdraws queued
|
|
1760
|
-
for later placement.
|
|
2036
|
+
`settled` for any terminal submission. Conversation abort withdraws queued
|
|
2037
|
+
steer/follow-up submissions but keeps writes for later placement.
|
|
1761
2038
|
|
|
1762
|
-
Boundary selection is deterministic by item ID
|
|
2039
|
+
Boundary selection is deterministic by item ID, with the conversation's
|
|
2040
|
+
configured `steeringMode` and `followUpMode` (section 2.2): `one-at-a-time`
|
|
2041
|
+
selects the first item of that mode, `all` every item of that mode.
|
|
1763
2042
|
|
|
1764
2043
|
| boundary | write | steer | follow-up |
|
|
1765
2044
|
|---|---|---|---|
|
|
1766
2045
|
| `postTools` | all | first/all by mode | none |
|
|
1767
2046
|
| `final` | all | first/all by mode | first/all by mode |
|
|
1768
2047
|
|
|
1769
|
-
A
|
|
1770
|
-
|
|
1771
|
-
|
|
2048
|
+
A boundary places its selected writes first, in ID order, and then its selected
|
|
2049
|
+
user items, in ID order. A user item queued before a reset or compaction summary
|
|
2050
|
+
therefore lands after it and runs in the new context. Queued user items never
|
|
2051
|
+
become stale because of a head.
|
|
2052
|
+
|
|
2053
|
+
A head write, queued or placed at once, whose target is older than the start of
|
|
2054
|
+
the active range, the newest head marker's `head`, is stale: placing it would bring back history that
|
|
2055
|
+
head cut. Heads placed earlier in the same boundary count: after a queued reset,
|
|
2056
|
+
a queued summary targeting an older entry is stale. A `head: "self"` write is
|
|
2057
|
+
never stale.
|
|
2058
|
+
|
|
2059
|
+
Compaction summaries are head writes (section 8.7), so this rule alone orders
|
|
2060
|
+
compactions by cut position: a summary cutting before the active range start is
|
|
2061
|
+
stale, and one cutting at or after it is placed, whenever it was selected.
|
|
2062
|
+
Example: background compaction B selects at tail 100 and cuts at 70. While it
|
|
2063
|
+
summarizes, a blocking compaction A cuts at 150 and appends its summary. B's
|
|
2064
|
+
summary then settles `stale`, because 70 is older than 150. Had A cut at 60
|
|
2065
|
+
instead, B would be placed after it: B summarized the context at its selection,
|
|
2066
|
+
the then-newest summary plus the entries before 70, which covers everything A's
|
|
2067
|
+
summary covers. That holds while the only heads are compaction summaries and
|
|
2068
|
+
resets, which make older cuts stale; an application edit placed while a
|
|
2069
|
+
compaction summarizes is lost, and an application head can be undone (section
|
|
2070
|
+
12).
|
|
2071
|
+
|
|
2072
|
+
A `postTools` boundary that selects a `head: "self"` write (a reset) behaves as
|
|
2073
|
+
`final`: it also selects follow-ups, and the current run ends with its inputs
|
|
2074
|
+
`unanswered` with reason `reset`, because its context was cut before an answer.
|
|
2075
|
+
A reset placed at a `final` boundary follows the answer, so the inputs are
|
|
2076
|
+
already `done`.
|
|
1772
2077
|
|
|
1773
2078
|
At ordinary `postTools`, generation continues even with no queued trigger;
|
|
1774
|
-
selected steer IDs join that continuation. A terminating
|
|
1775
|
-
|
|
1776
|
-
input submissions settle first
|
|
1777
|
-
|
|
1778
|
-
|
|
2079
|
+
selected steer IDs join that continuation. A terminating or handoff tool round
|
|
2080
|
+
(section 8.5) uses final behavior instead. At `final`, the current run's placed
|
|
2081
|
+
input submissions settle first, unless an `onYield` continuation keeps them open
|
|
2082
|
+
(below); selected user IDs start one successor generation with those IDs as the
|
|
2083
|
+
new run's inputs. Writes never trigger generation by
|
|
2084
|
+
themselves. A final boundary without user triggers leaves the conversation idle,
|
|
2085
|
+
except for an `onYield` continuation (section 8.3), which applies only when the
|
|
2086
|
+
boundary selected no user item and no reset.
|
|
2087
|
+
|
|
2088
|
+
Only successful run ends apply the final boundary: an answer, `terminate`, or
|
|
2089
|
+
`handoff`. Failure, a run task's abort handler, fault, and orphan settle the
|
|
2090
|
+
run's inputs `unanswered` and leave the inbox alone (`Conversation.abort()`
|
|
2091
|
+
separately withdraws queued user items); the queued items stay visible in
|
|
2092
|
+
`pi.inbox` until the next submission's boundary or their withdrawal.
|
|
1779
2093
|
|
|
1780
2094
|
Selected and stale items are removed positionally while retained item order is
|
|
1781
2095
|
preserved. Chord's Astra operation generator must express scattered removals
|
|
@@ -1957,7 +2271,7 @@ hooks read that snapshot and never take their own. At every normal phase boundar
|
|
|
1957
2271
|
the scheduler takes a fresh one (section 5.4). A tool task keeps the composed tool
|
|
1958
2272
|
it pinned until execution settles. Different phases may observe different
|
|
1959
2273
|
registry states; nothing requires one run to see a single registry state across
|
|
1960
|
-
its generation
|
|
2274
|
+
its generation and tool tasks. Host operations, such as the create
|
|
1961
2275
|
conveniences, take one snapshot inside their commit.
|
|
1962
2276
|
|
|
1963
2277
|
```ts
|
|
@@ -1997,14 +2311,91 @@ errors always propagate.
|
|
|
1997
2311
|
| `beforeRequest` | replacement chain | report, continue |
|
|
1998
2312
|
| `afterResponse` | all observers | report, continue |
|
|
1999
2313
|
| `onYield` | first continuation wins | report, continue |
|
|
2000
|
-
| `beforeTool` |
|
|
2314
|
+
| `beforeTool` | argument replacement chain; first block wins | block tool with error text |
|
|
2001
2315
|
| `afterTool` | result replacement chain | report, continue |
|
|
2002
2316
|
| `afterTools` | all observers | report, continue |
|
|
2003
|
-
| `
|
|
2317
|
+
| `beforeCompact` | first decision wins | report, continue |
|
|
2004
2318
|
|
|
2005
2319
|
Hooks use task memos for durable first-writer-wins decisions. There is no public
|
|
2006
2320
|
semantic event channel; current UI status is document state.
|
|
2007
2321
|
|
|
2322
|
+
```ts
|
|
2323
|
+
type HookResult<T> = T | undefined | Promise<T | undefined>;
|
|
2324
|
+
|
|
2325
|
+
/** What a hook may use: committed reads and the asking task's memos. */
|
|
2326
|
+
interface HookApi extends DocumentReader {
|
|
2327
|
+
readonly taskId: TaskId;
|
|
2328
|
+
readonly conversationId: ConversationId;
|
|
2329
|
+
memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
|
|
2330
|
+
memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
interface GenerationHooks {
|
|
2334
|
+
/** Before every request attempt, including recovery; the result is used for that request only. */
|
|
2335
|
+
beforeRequest(
|
|
2336
|
+
request: { readonly messages: readonly Message[] },
|
|
2337
|
+
api: HookApi,
|
|
2338
|
+
context: Context,
|
|
2339
|
+
): HookResult<{ readonly messages: readonly Message[] }>;
|
|
2340
|
+
/** Every terminal provider message, before classification. */
|
|
2341
|
+
afterResponse(message: AssistantMessage, api: HookApi, context: Context): void | Promise<void>;
|
|
2342
|
+
/** A final answer; `continue` appends a user message and continues the run. */
|
|
2343
|
+
onYield(answer: AssistantMessage, api: HookApi, context: Context): HookResult<{ readonly continue: UserInput }>;
|
|
2344
|
+
/** After every tool of the round is terminal; `results` are the round's result entries in call order. */
|
|
2345
|
+
afterTools(assistant: EntryId, results: readonly EntryId[], api: HookApi, context: Context): void | Promise<void>;
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
interface ToolHooks {
|
|
2349
|
+
/** Before intent; replaces the arguments or blocks the call with error text. */
|
|
2350
|
+
beforeTool(
|
|
2351
|
+
call: ToolCall,
|
|
2352
|
+
api: HookApi,
|
|
2353
|
+
context: Context,
|
|
2354
|
+
): HookResult<{ readonly arguments?: JsonObject; readonly block?: string }>;
|
|
2355
|
+
/** After execution, before the result entry; replaces the result. */
|
|
2356
|
+
afterTool(
|
|
2357
|
+
call: ToolCall,
|
|
2358
|
+
result: ToolExecutionResult,
|
|
2359
|
+
api: HookApi,
|
|
2360
|
+
context: Context,
|
|
2361
|
+
): HookResult<ToolExecutionResult>;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
interface CompactionHooks {
|
|
2365
|
+
/**
|
|
2366
|
+
* After range selection, before summarizing; the first decision wins. `entries` are the active entries the summary
|
|
2367
|
+
* replaces, the head marker first, and `messages` their model context, the summarizer's source; `firstKept` is the
|
|
2368
|
+
* first entry kept verbatim.
|
|
2369
|
+
*/
|
|
2370
|
+
beforeCompact(
|
|
2371
|
+
compaction: {
|
|
2372
|
+
readonly reason: CompactionReason;
|
|
2373
|
+
readonly entries: readonly EntryRecord[];
|
|
2374
|
+
readonly messages: readonly Message[];
|
|
2375
|
+
readonly firstKept: EntryId;
|
|
2376
|
+
readonly instructions?: string;
|
|
2377
|
+
},
|
|
2378
|
+
api: HookApi,
|
|
2379
|
+
context: Context,
|
|
2380
|
+
): HookResult<{ readonly decline: true } | { readonly summary: string }>;
|
|
2381
|
+
}
|
|
2382
|
+
```
|
|
2383
|
+
|
|
2384
|
+
`GenerationTask`, `ToolTask`, and `CompactionTask` are the exported built-in task
|
|
2385
|
+
tokens whose `H` parameters are these interfaces. `runtime.hooks.each(name,
|
|
2386
|
+
invoke)` calls `invoke` with every handler registered under `name` whose
|
|
2387
|
+
registration matches the task's conversation, in registry order of the phase
|
|
2388
|
+
snapshot. A registration matches when it has no scope, when its scope names the
|
|
2389
|
+
task's conversation, or, with `subtree`, when the task's conversation is owned
|
|
2390
|
+
transitively through tasks of the scope's conversation. Owner edges never change,
|
|
2391
|
+
so the Harness may cache each conversation's owner chain. An ordinary throw from
|
|
2392
|
+
`invoke` is reported through `onReport` and `each` continues with the next
|
|
2393
|
+
handler; once the invocation is signalled, the error propagates. A task that
|
|
2394
|
+
composes differently, such as `beforeTool` turning a throw into a block, or stops
|
|
2395
|
+
after a first decision, does so inside its own `invoke`. Hooks receive the task's
|
|
2396
|
+
runtime as their `HookApi`; hook memos and the task's own memos share one
|
|
2397
|
+
namespace, so hook authors prefix their memo names.
|
|
2398
|
+
|
|
2008
2399
|
### 7.3 Tools
|
|
2009
2400
|
|
|
2010
2401
|
```ts
|
|
@@ -2014,10 +2405,20 @@ type ToolControl = {
|
|
|
2014
2405
|
readonly handoff?: string;
|
|
2015
2406
|
};
|
|
2016
2407
|
|
|
2408
|
+
/** Remark about a call for the model and the UI; never part of the tool's data. */
|
|
2409
|
+
type ToolDiagnostic = {
|
|
2410
|
+
readonly severity: "info" | "warn" | "error";
|
|
2411
|
+
readonly message: string;
|
|
2412
|
+
readonly code?: string;
|
|
2413
|
+
};
|
|
2414
|
+
|
|
2017
2415
|
type ToolExecutionResult = {
|
|
2018
2416
|
readonly content?: ToolResultMessage["content"];
|
|
2019
2417
|
readonly isError?: boolean;
|
|
2020
2418
|
readonly details?: JsonValue;
|
|
2419
|
+
readonly diagnostics?: readonly ToolDiagnostic[];
|
|
2420
|
+
/** Spend of the execution itself, such as a model call; stored on the result and in `pi.usage.tools`. */
|
|
2421
|
+
readonly usage?: Usage;
|
|
2021
2422
|
readonly control?: ToolControl;
|
|
2022
2423
|
};
|
|
2023
2424
|
|
|
@@ -2032,7 +2433,10 @@ interface ToolExecutionApi extends DocumentObserver, DocumentReader {
|
|
|
2032
2433
|
readonly taskId: TaskId;
|
|
2033
2434
|
readonly conversationId: ConversationId;
|
|
2034
2435
|
readonly callId: string;
|
|
2436
|
+
/** `HarnessOptions.env` unless a wrapper supplies another environment. */
|
|
2437
|
+
readonly env: ExecutionEnv | undefined;
|
|
2035
2438
|
output(chunk: string | Uint8Array): void;
|
|
2439
|
+
diagnostic(diagnostic: ToolDiagnostic): void;
|
|
2036
2440
|
details(value: JsonValue, context: Context): Promise<void>;
|
|
2037
2441
|
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
|
|
2038
2442
|
memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
|
|
@@ -2050,6 +2454,9 @@ interface ToolExecutionApi extends DocumentObserver, DocumentReader {
|
|
|
2050
2454
|
|
|
2051
2455
|
type ToolRegistration = Tool & {
|
|
2052
2456
|
readonly replay?: "safe" | "unsafe";
|
|
2457
|
+
readonly executionMode?: ToolExecutionMode;
|
|
2458
|
+
/** Pure repair of commonly malformed arguments; runs before validation and must not mutate `args`. */
|
|
2459
|
+
prepareArguments?(args: JsonValue): JsonValue;
|
|
2053
2460
|
readonly outputLimits?: {
|
|
2054
2461
|
readonly maxBytes?: number;
|
|
2055
2462
|
readonly maxLines?: number;
|
|
@@ -2064,14 +2471,41 @@ type ToolRegistration = Tool & {
|
|
|
2064
2471
|
```
|
|
2065
2472
|
|
|
2066
2473
|
Omitted `replay` is `unsafe`. Omitted `outputLimits` are 50 KiB, 2,000 lines,
|
|
2067
|
-
and `retain: "head"`.
|
|
2474
|
+
and `retain: "head"`. Omitted `executionMode` follows the conversation's
|
|
2475
|
+
`toolExecution`; one `sequential` call makes its whole round sequential
|
|
2476
|
+
(section 8.3).
|
|
2068
2477
|
|
|
2069
|
-
|
|
2070
|
-
|
|
2478
|
+
Tools reach files and processes only through `api.env`, never through an
|
|
2479
|
+
environment captured at registration, so the environment is chosen per call. The
|
|
2480
|
+
Harness supplies `HarnessOptions.env`. A wrapper may supply another, for example
|
|
2481
|
+
a per-conversation working directory or sandbox:
|
|
2482
|
+
|
|
2483
|
+
```ts
|
|
2484
|
+
registry.tools.wrap("bash", "workspace", (tool) => ({
|
|
2485
|
+
...tool,
|
|
2486
|
+
execute: async (args, api, context) => {
|
|
2487
|
+
const workspace = await api.snapshot(WorkspaceDoc, api.conversationId, context);
|
|
2488
|
+
return tool.execute(args, { ...api, env: environmentFor(workspace) }, context);
|
|
2489
|
+
},
|
|
2490
|
+
}));
|
|
2491
|
+
```
|
|
2492
|
+
|
|
2493
|
+
A tool rerun after recovery must get the same environment. When the state a
|
|
2494
|
+
wrapper reads can change while a call runs, the wrapper memoizes its choice with
|
|
2495
|
+
`api.memo()`. `api` is a plain object so wrappers can spread
|
|
2496
|
+
it. A tool that needs an environment and receives none throws, which produces an
|
|
2497
|
+
ordinary error result.
|
|
2498
|
+
|
|
2499
|
+
A running tool reports output and details to the UI, mirroring the two halves of
|
|
2500
|
+
its final result, plus diagnostics (below):
|
|
2071
2501
|
|
|
2072
2502
|
- `output(chunk)` appends running text output, like stdout. If `execute()` omits
|
|
2073
2503
|
`content`, the final retained output becomes one text content item; no output
|
|
2074
|
-
becomes an empty content list.
|
|
2504
|
+
becomes an empty content list. Retained output is an exact slice of whole lines
|
|
2505
|
+
of the stream (the first lines for `head`, the last for `tail`), trailing
|
|
2506
|
+
newline included; a single line longer than `maxBytes` is cut at the byte limit
|
|
2507
|
+
on a character boundary. Control characters other than tab and newline are
|
|
2508
|
+
removed from the retained text; accepting a chunk does no per-chunk sanitizing.
|
|
2075
2509
|
- `details(value)` replaces the running details with a complete JSON value; it
|
|
2076
2510
|
does not merge keys. If `execute()` omits `details`, the last value becomes the
|
|
2077
2511
|
final `details`, so a renderer handles one details shape from the first
|
|
@@ -2080,19 +2514,56 @@ final result:
|
|
|
2080
2514
|
Neither is sent to the model while the tool runs. `output()` synchronously
|
|
2081
2515
|
accepts UTF-8 output into that invocation-owned bounded buffer and throws after
|
|
2082
2516
|
invocation end. Throttled commits publish the retained output, dropped
|
|
2083
|
-
byte/line counts, and the current details
|
|
2517
|
+
byte/line counts, and the current details and diagnostics in its `pi.live.tools`
|
|
2518
|
+
slot. The throttle is adaptive, like the environment's shell output capture: the
|
|
2519
|
+
first change after an idle period commits at once; each commit then delays the
|
|
2520
|
+
next by at least 100 ms and by its written size at 100 KiB/s, so a large
|
|
2521
|
+
rewrite buys a proportionally longer pause. Changes made during the delay
|
|
2522
|
+
coalesce into the next commit. The throttle is Harness policy, not part of
|
|
2523
|
+
`outputLimits`, which only bounds what is retained. Explicit
|
|
2084
2524
|
text in explicit result content is bounded by the same limits before transcript
|
|
2085
|
-
persistence; non-text content is retained as declared by its pi-ai type.
|
|
2525
|
+
persistence; non-text content is retained as declared by its pi-ai type. When
|
|
2526
|
+
bounding drops text, the Harness adds a `warn` diagnostic with code `truncated`
|
|
2527
|
+
stating the dropped lines and bytes.
|
|
2528
|
+
|
|
2529
|
+
Diagnostics are a channel, not text. Remarks about a call, such as truncated
|
|
2530
|
+
output, a spill path, a corrected path, a file changed on disk, or a capped
|
|
2531
|
+
search, go through `api.diagnostic()` or `ToolExecutionResult.diagnostics`,
|
|
2532
|
+
never into the content the model reads as the tool's data. The Harness adds the
|
|
2533
|
+
ones it owns, and a tool adds only what it alone knows. Every diagnostic is
|
|
2534
|
+
model-visible; information only for UIs belongs in `details`. `diagnostic()`
|
|
2535
|
+
synchronously records the diagnostic in the tool's slot with the next throttled
|
|
2536
|
+
commit and throws after invocation end. At settlement the result's diagnostics
|
|
2537
|
+
are those recorded through `api`, then those in the result, then the Harness's.
|
|
2538
|
+
When there are any, the result content ends with one text item:
|
|
2539
|
+
|
|
2540
|
+
```text
|
|
2541
|
+
<harness>
|
|
2542
|
+
[warn] output truncated: 51,204 lines, 2,301,112 bytes dropped
|
|
2543
|
+
</harness>
|
|
2544
|
+
```
|
|
2545
|
+
|
|
2546
|
+
with one `[severity] message` line per diagnostic, and the `pi.tool-result`
|
|
2547
|
+
entry stores the structured list, possibly empty, as `data: { diagnostics }`
|
|
2548
|
+
(section 8.1). The
|
|
2549
|
+
stored message is exactly what the model saw, while UIs and code read the list.
|
|
2550
|
+
A `warn` diagnostic does not set `isError`.
|
|
2086
2551
|
|
|
2087
2552
|
`output()` never spills complete output to a file because spilling requires a
|
|
2088
2553
|
filesystem, which may be remote or unavailable. A tool that must preserve
|
|
2089
2554
|
complete output spills through the `ExecutionEnv` or `FileSystem` it was given,
|
|
2090
|
-
|
|
2091
|
-
|
|
2555
|
+
and reports the resulting path in a diagnostic, and in its details when a
|
|
2556
|
+
renderer needs it. The environment's shell streams raw output chunks and spills
|
|
2557
|
+
the complete output to a file once it crosses byte or line thresholds; it keeps
|
|
2558
|
+
no bounded view of its own, so `output()` is the one place output is bounded,
|
|
2559
|
+
sanitized, and throttled. The `bash` tool pipes those chunks into `output()`,
|
|
2560
|
+
reports the spill path as a diagnostic, and throws on a nonzero exit or timeout;
|
|
2561
|
+
the error result still carries the retained output and diagnostics.
|
|
2092
2562
|
|
|
2093
2563
|
The `details()` promise resolves after the corresponding or coalesced document
|
|
2094
|
-
commit. During normal settlement
|
|
2095
|
-
|
|
2564
|
+
commit. During normal settlement the tool task stops its throttle and awaits the
|
|
2565
|
+
commit in flight; the terminal commit, which appends the result entry, is the
|
|
2566
|
+
final flush and settles any `details()` promise still pending. Abort and close obey
|
|
2096
2567
|
invocation and Session admission gates: uncommitted buffered updates may be
|
|
2097
2568
|
discarded, while admitted commits settle. Cancellation, callback, tracker
|
|
2098
2569
|
preparation, and checkpoint failures occur before Storage admission and do not
|
|
@@ -2108,23 +2579,30 @@ not offered, and calls produce `tool_unavailable`) and is reported. A wrapper
|
|
|
2108
2579
|
without a base contributes nothing. The composite supplies the declaration,
|
|
2109
2580
|
argument validation, replay policy, and execution.
|
|
2110
2581
|
|
|
2111
|
-
A tool call is accepted only if
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2582
|
+
A tool call is accepted only if its request offered the tool. Generation checks
|
|
2583
|
+
this against the tool set replayed from the committed model context through the
|
|
2584
|
+
request's `cutoff`; `beforeRequest` replacements do not change it. It answers a
|
|
2585
|
+
call to a tool it did not offer with the `tool_unavailable` result described in
|
|
2586
|
+
section 2.2, without a tool task. A configuration change after preparation does
|
|
2587
|
+
not affect this check (section 2.2). The tool task resolves the composed tool
|
|
2588
|
+
once from its phase snapshot and uses that implementation until execution
|
|
2589
|
+
settles: one phase handler resolves, validates, runs `beforeTool`, records intent,
|
|
2590
|
+
executes, and commits the result, so no phase boundary separates resolution from
|
|
2591
|
+
settlement. An unregistered implementation produces `tool_unavailable`.
|
|
2592
|
+
The implementation's `prepareArguments`, if any, first repairs the call's
|
|
2593
|
+
arguments, for example `edits` sent as a JSON string; the stored call keeps what
|
|
2594
|
+
the model sent. Arguments are validated against the resolved implementation's
|
|
2595
|
+
schema before and after `beforeTool`; a failure, or a throwing repair, produces an
|
|
2596
|
+
`invalid_arguments` error result.
|
|
2597
|
+
|
|
2598
|
+
After hooks and validation, the tool task durably records the final arguments and
|
|
2121
2599
|
resolved replay policy before execution. Recovery does not rerun `beforeTool`
|
|
2122
2600
|
and passes the same stored arguments to `execute()`. A replay-safe tool may
|
|
2123
2601
|
reconstruct a submission from those arguments when that transformation is pure.
|
|
2124
2602
|
Random values, timestamps, mutable document/configuration reads, or other derived
|
|
2125
2603
|
inputs that must remain stable are first captured in a durable memo, checkpoint,
|
|
2126
|
-
or task input. A background
|
|
2127
|
-
|
|
2604
|
+
or task input. A background reporter task receives the final message in its own
|
|
2605
|
+
durable input so it can finish independently. Recovery does not let a
|
|
2128
2606
|
changed registry declaration alter the stored replay policy.
|
|
2129
2607
|
|
|
2130
2608
|
A tool-acquired conversation handle accepts only input submissions; tools use
|
|
@@ -2132,7 +2610,7 @@ ordinary transaction writes for passive entries.
|
|
|
2132
2610
|
|
|
2133
2611
|
A tool executes in a durable task. It may:
|
|
2134
2612
|
|
|
2135
|
-
- publish bounded running output and details to its
|
|
2613
|
+
- publish bounded running output and details to its `pi.live.tools` slot;
|
|
2136
2614
|
- commit memos;
|
|
2137
2615
|
- create and wait for tasks;
|
|
2138
2616
|
- atomically create or fork explicitly owned conversations through `commit()`;
|
|
@@ -2159,54 +2637,75 @@ the same way; the admitted submission remains durable after those methods reject
|
|
|
2159
2637
|
Invocation-owned document watches stop when the invocation ends.
|
|
2160
2638
|
|
|
2161
2639
|
A foreground subagent conversation is explicitly owned by its tool task. One
|
|
2162
|
-
transaction creates or forks the child
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
child
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
`
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2640
|
+
transaction creates or forks the child; the owner edge is its durable record, so
|
|
2641
|
+
a replay-safe rerun finds the child with `scanConversations({ ownerTaskId })`
|
|
2642
|
+
before creating one. After settlement the tool reacquires the child, submits
|
|
2643
|
+
with a request ID derived from its task ID, which a rerun keeps, and waits for
|
|
2644
|
+
that submission's result. Provider call IDs are not unique across a Session and
|
|
2645
|
+
must not serve as request IDs. Aborting or abnormally
|
|
2646
|
+
terminalizing the tool task cascades through the owned scope. The tool reports
|
|
2647
|
+
the child in its running details, for example `api.details({ conversationId })`,
|
|
2648
|
+
so a UI that sees the call can attach to the child's view or events.
|
|
2649
|
+
|
|
2650
|
+
A persistent background subagent outlives the parent's turns and can be
|
|
2651
|
+
messaged, steered, stopped, and listed later. One transaction, after
|
|
2652
|
+
deduplicating by the subagent's name in an application registry document,
|
|
2653
|
+
creates a background conversation-owned anchor task `A` in the parent, a child
|
|
2654
|
+
conversation `C` owned by `A`, and the registry mapping from the name to `C`.
|
|
2655
|
+
`A` completes at once; it holds `completing` while `C` has ordinary work
|
|
2656
|
+
(section 5.5) and then stays terminal. Its record keeps `background`, so the
|
|
2657
|
+
parent's ordinary abort and idle traversal stop at it, while
|
|
2658
|
+
`Conversation.abort(context, { background: true })` still reaches `C`'s work
|
|
2659
|
+
through it, and `Harness.waitForIdle()` does not wait for it. Later runs in `C`
|
|
2660
|
+
are ordinary work of `C`; a terminal owner never cascades.
|
|
2661
|
+
|
|
2662
|
+
Each message to a subagent is delivered by a background conversation-owned
|
|
2663
|
+
reporter task in the parent, created in the tool's transaction and keyed there
|
|
2664
|
+
by the tool task ID so a rerun of the call does not create another. The
|
|
2665
|
+
reporter submits to `C` with a request ID derived from its own task ID, waits
|
|
2666
|
+
for that submission's settlement, and posts the answer to the parent as an
|
|
2667
|
+
input with `whenBusy: "followUp"`, under another request ID derived from its
|
|
2668
|
+
task ID. A crash before either admission retries it; a crash after returns the
|
|
2669
|
+
existing `Submission`, so the child gets the message once and the parent gets
|
|
2670
|
+
the answer once. Submission admission therefore needs no private transaction
|
|
2671
|
+
shortcut and does not move onto `Tx`. A registry of names uses `fork:
|
|
2672
|
+
"initial"` so forks of the parent do not inherit it. A UI lists subagents from
|
|
2673
|
+
the registry; a child is working while its `pi.live.run` is set. Owner edges
|
|
2674
|
+
alone drive abort and idle traversal.
|
|
2675
|
+
|
|
2676
|
+
A tool result may request `addTools`, `terminate`, or `handoff`. The
|
|
2677
|
+
generation's `tools` phase (section 8.5) appends added tool names to the
|
|
2678
|
+
configured loadout; they take effect at the next preparation. The round
|
|
2679
|
+
terminates only when every result of the round requests `terminate`, as in the
|
|
2680
|
+
pi agent loop; the `tools` phase then uses a final boundary. Any
|
|
2681
|
+
`handoff` in the round, the last one in call order when several ask, ends the
|
|
2682
|
+
run the same way after appending a `pi.reset` entry with `head: "self"` and the
|
|
2683
|
+
handoff text as a user message (section 8.1), exactly what `reset(handoff)`
|
|
2684
|
+
writes.
|
|
2685
|
+
|
|
2686
|
+
A result's `usage` is stored on the tool-result message and added to the
|
|
2687
|
+
conversation's `pi.usage.tools[toolName]` in the result commit (section 8.6). A
|
|
2688
|
+
tool that runs an owned conversation must not report that conversation's spend
|
|
2689
|
+
again: the child's own `pi.usage` already counts it (section 12).
|
|
2202
2690
|
|
|
2203
2691
|
On reopen, a tool reruns only when both its stored intent policy and the current
|
|
2204
2692
|
registered declaration say `safe`. A current `unsafe` declaration may veto a
|
|
2205
2693
|
stored-safe replay; a current-safe declaration never upgrades stored unsafe. A
|
|
2206
2694
|
tool with no current registration is treated as `unsafe`. Every other orphaned
|
|
2207
2695
|
effect produces an interrupted result containing the
|
|
2208
|
-
durable partial output. Completed, failed, and aborted
|
|
2209
|
-
|
|
2696
|
+
durable partial output. Completed, failed, and aborted tool terminal outcomes
|
|
2697
|
+
retain their tool-result entry ID for the generation's `tools` phase.
|
|
2698
|
+
|
|
2699
|
+
Error results the Harness writes itself set `isError` and carry an `error`
|
|
2700
|
+
diagnostic with one of the codes `tool_unavailable`, `invalid_arguments`,
|
|
2701
|
+
`blocked`, `interrupted`, `aborted`, or `tool_error` (a throw from `execute()`)
|
|
2702
|
+
and the error text as its message. Their content is the durable partial output,
|
|
2703
|
+
if any, and `details` is the tool's last reported value, if any.
|
|
2704
|
+
|
|
2705
|
+
`@earendil-works/pi-durable/tools` provides `read`, `bash`, `edit`, and `write`
|
|
2706
|
+
factories, ported from the agent harness tools. They use only `api.env` and are
|
|
2707
|
+
registered like any other tool; nothing registers them automatically. `read`
|
|
2708
|
+
does not return images yet.
|
|
2210
2709
|
|
|
2211
2710
|
### 7.4 System prompt and dynamic tools
|
|
2212
2711
|
|
|
@@ -2254,8 +2753,9 @@ Generation preparation takes these steps against its phase snapshot:
|
|
|
2254
2753
|
append one positional `pi.system` entry when they differ.
|
|
2255
2754
|
|
|
2256
2755
|
Preparation does not recheck the transcript before appending: only the Harness
|
|
2257
|
-
writes to a busy conversation, through submissions, run tasks, and
|
|
2258
|
-
|
|
2756
|
+
writes to a busy conversation, through submissions, run tasks, boundaries, and a
|
|
2757
|
+
blocking compaction, which appends only while its generation waits for it
|
|
2758
|
+
(section 8.7), so the transcript it read is still current (section 12).
|
|
2259
2759
|
|
|
2260
2760
|
Model and thinking level are request options, not prompt state.
|
|
2261
2761
|
|
|
@@ -2303,7 +2803,7 @@ A PR #9548 `SystemMessage` is always a patch, not a reset: it cannot remove
|
|
|
2303
2803
|
previous `content` or restore section order merely by restating current values.
|
|
2304
2804
|
Therefore, when a head removes the previous request-visible baseline, which is
|
|
2305
2805
|
the case when the active context has a head marker and no `pi.system` entry
|
|
2306
|
-
|
|
2806
|
+
was appended after that marker (has a higher ID), the new
|
|
2307
2807
|
`pi.system` entry adds `ContextEdit` omissions for every earlier `pi.system`
|
|
2308
2808
|
entry still retained after the cut. Its own message is then a complete baseline
|
|
2309
2809
|
containing every desired section in order and every effective tool declaration.
|
|
@@ -2313,10 +2813,16 @@ values, so every later preparation finds a `pi.system` entry after the marker.
|
|
|
2313
2813
|
Head rebaselining takes precedence over ordinary order/value patching.
|
|
2314
2814
|
Without a head cut, an order mismatch uses the two-entry remove/re-add sequence
|
|
2315
2815
|
above; only when order already matches does preparation emit the minimal changed
|
|
2316
|
-
values
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2816
|
+
values and `null` removals.
|
|
2817
|
+
|
|
2818
|
+
Tool changes are planned separately and ride on the last planned entry, or on
|
|
2819
|
+
one entry of their own when sections are unchanged. Declarations are compared
|
|
2820
|
+
with pi-ai `declarationsEqual()` and written with `toToolDeclaration()`, so
|
|
2821
|
+
application metadata never enters the transcript. A changed declaration is
|
|
2822
|
+
removed and re-added in the same message. Replay keeps retained tools in place
|
|
2823
|
+
and appends additions; when that would not yield the desired order, the message
|
|
2824
|
+
removes every offered tool and re-adds the desired tools in order. A head
|
|
2825
|
+
rebaseline adds every desired tool.
|
|
2320
2826
|
|
|
2321
2827
|
The rendered strings stored in historical `SystemMessage.sections` remain
|
|
2322
2828
|
authoritative even if the current renderer changes. Pi-ai decides whether to
|
|
@@ -2359,8 +2865,7 @@ The initial implementation provides:
|
|
|
2359
2865
|
|---|---|
|
|
2360
2866
|
| `pi.generation` | prepare system/loadout, request or poll model, retry, classify response |
|
|
2361
2867
|
| `pi.tool` | validate, hook, execute, persist output and details, append result |
|
|
2362
|
-
| `pi.
|
|
2363
|
-
| `pi.collapse` | select a transcript range, summarize, append a headed summary |
|
|
2868
|
+
| `pi.compaction` | select a transcript range, summarize, place a headed summary |
|
|
2364
2869
|
|
|
2365
2870
|
Generation uses `HarnessOptions.models` without a Pico-specific model adapter. It
|
|
2366
2871
|
resolves `models.getModel(ref.provider, ref.modelId)`, builds a pi-ai `Context`
|
|
@@ -2376,23 +2881,29 @@ to normal interrupted/aborted transcript entries, clears presentation state,
|
|
|
2376
2881
|
and then retries or terminates according to the task phase. Retry deadlines,
|
|
2377
2882
|
attempts, compaction, and tool progress are current document state for late
|
|
2378
2883
|
joiners; completed-attempt usage/accounting is an entry or terminal detail.
|
|
2379
|
-
|
|
2884
|
+
Truncation and spill paths are tool diagnostics (section 7.3).
|
|
2380
2885
|
|
|
2381
2886
|
Compaction changes model context by appending a summary entry with a head. It
|
|
2382
2887
|
does not delete transcript history.
|
|
2383
2888
|
|
|
2384
2889
|
### 8.1 Built-in entries
|
|
2385
2890
|
|
|
2386
|
-
Built-in entry kinds carry no `data
|
|
2891
|
+
Built-in entry kinds carry no `data`, except `pi.tool-result`, whose token is
|
|
2892
|
+
`Entry<{ diagnostics: ToolDiagnostic[] }>`; every tool result carries `data`,
|
|
2893
|
+
with an empty list when it has no diagnostics (section 7.3), and
|
|
2894
|
+
`pi.compaction`, whose token is `Entry<{ reason: CompactionReason }>`. Each kind
|
|
2895
|
+
is exported as an `Entry` token.
|
|
2387
2896
|
|
|
2388
2897
|
| kind | `model` | written by |
|
|
2389
2898
|
|---|---|---|
|
|
2390
|
-
| `pi.user` | `[UserMessage]`, timestamp from the Harness clock at admission or placement | submissions |
|
|
2899
|
+
| `pi.user` | `[UserMessage]`, timestamp from the Harness clock at admission or placement | submissions, `onYield` continuations |
|
|
2391
2900
|
| `pi.assistant` | `[AssistantMessage]` with any stop reason | generation |
|
|
2392
2901
|
| `pi.system` | `[SystemMessage]` with `content: ""` (section 7.4) | generation preparation |
|
|
2393
|
-
| `pi.tool-result` | `[ToolResultMessage]` | tool tasks |
|
|
2902
|
+
| `pi.tool-result` | `[ToolResultMessage]` | tool tasks; generation for calls to tools its request did not offer |
|
|
2903
|
+
| `pi.reset` | absent, or `[UserMessage]` with the handoff text; always `head: "self"` | `reset()`, generation `tools` phase for `handoff` |
|
|
2904
|
+
| `pi.compaction` | `[UserMessage]` with the wrapped summary; `head` is the first kept entry | compaction tasks (section 8.7) |
|
|
2394
2905
|
|
|
2395
|
-
Every
|
|
2906
|
+
Every generation response becomes a `pi.assistant` entry: answers, failed attempts
|
|
2396
2907
|
with their error text and usage, and converted partials with stop reason
|
|
2397
2908
|
`aborted`. Context derivation (section 2.1, rule 9) keeps failed and aborted
|
|
2398
2909
|
messages out of later requests, so no separate usage or notice kind exists.
|
|
@@ -2413,6 +2924,39 @@ type LiveState = {
|
|
|
2413
2924
|
/** Provider-side deferred response being polled. */
|
|
2414
2925
|
deferred?: { pollAt: number };
|
|
2415
2926
|
};
|
|
2927
|
+
/** The current tool round in call order, from the tool-calling answer until the generation's `tools` phase ends it. */
|
|
2928
|
+
tools?: {
|
|
2929
|
+
callId: string;
|
|
2930
|
+
name: string;
|
|
2931
|
+
/**
|
|
2932
|
+
* Absent for a call not started yet (sequential round, `pending`) and for a call its request did not offer,
|
|
2933
|
+
* which starts `done` with the `entry` generation wrote.
|
|
2934
|
+
*/
|
|
2935
|
+
taskId?: TaskId;
|
|
2936
|
+
status: "pending" | "running" | "done";
|
|
2937
|
+
/** Retained running output and what the bounds dropped. */
|
|
2938
|
+
output?: string;
|
|
2939
|
+
droppedBytes?: number;
|
|
2940
|
+
droppedLines?: number;
|
|
2941
|
+
/** Last `details()` value. */
|
|
2942
|
+
details?: JsonValue;
|
|
2943
|
+
/** Diagnostics recorded through `api.diagnostic()`. */
|
|
2944
|
+
diagnostics?: ToolDiagnostic[];
|
|
2945
|
+
/** Result entry once done; absent when the tool task faulted or was orphaned. */
|
|
2946
|
+
entry?: EntryId;
|
|
2947
|
+
}[];
|
|
2948
|
+
/** Live compaction tasks in task ID order; absent when none (section 8.7). */
|
|
2949
|
+
compactions?: CompactionStatus[];
|
|
2950
|
+
};
|
|
2951
|
+
|
|
2952
|
+
type CompactionStatus = {
|
|
2953
|
+
taskId: TaskId<CompactionResult>;
|
|
2954
|
+
reason: CompactionReason;
|
|
2955
|
+
/** Whether a generation waits for it: a compaction the generation owns. */
|
|
2956
|
+
blocking: boolean;
|
|
2957
|
+
attempt: number;
|
|
2958
|
+
/** Durable backoff before the next summarization attempt. */
|
|
2959
|
+
retry?: { at: number; error: string };
|
|
2416
2960
|
};
|
|
2417
2961
|
```
|
|
2418
2962
|
|
|
@@ -2422,18 +2966,39 @@ type LiveState = {
|
|
|
2422
2966
|
| version | `1` |
|
|
2423
2967
|
| scope/history/fork | conversation, `latest`, `initial` |
|
|
2424
2968
|
| `initial()` | `{}` |
|
|
2425
|
-
| checkpoint | complete base whenever `generation`
|
|
2969
|
+
| checkpoint | complete base whenever nothing runs: `generation` absent and no `running` tool slot |
|
|
2426
2970
|
| view mount | `docs["pi.live"]` |
|
|
2427
2971
|
| created | with every Harness conversation (section 2.2) |
|
|
2428
2972
|
|
|
2429
|
-
Nothing
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2973
|
+
Nothing runs while idle, at a final boundary, in the commit where generation
|
|
2974
|
+
hands over to its tool round (every slot is still `pending` or `done`), between
|
|
2975
|
+
the tools of a sequential round, and whenever no tool of a parallel round is
|
|
2976
|
+
`running`. A slot holds output only while `running`, so every base is small, and
|
|
2977
|
+
the stored delta chain spans at most one generation, including its retries and
|
|
2978
|
+
deferred polls, or the overlapping execution of one round's tools. Retained head output grows by Chord string appends. A sliding tail
|
|
2979
|
+
usually becomes a front trim plus an append; when Chord's bounded overlap search
|
|
2980
|
+
finds no shared part, as with highly repetitive output or a window larger than
|
|
2981
|
+
its 64 KiB scan, the commit writes the retained window as one set. Either way
|
|
2982
|
+
each commit writes at most one window, and the throttle bounds the rate.
|
|
2983
|
+
|
|
2984
|
+
Generation creates `tools` with one `pending` slot per call in the commit that
|
|
2985
|
+
appends the tool-calling answer; a call its request did not offer starts `done`
|
|
2986
|
+
with its result entry. A tool task sets its slot `running` in its intent commit,
|
|
2987
|
+
publishes throttled output and details into it, and in its terminal commit sets
|
|
2988
|
+
`done` and `entry` and removes `output`, `droppedBytes`, `droppedLines`,
|
|
2989
|
+
`details`, and `diagnostics`, which the result entry now carries (section 7.3).
|
|
2990
|
+
The generation's `tools` phase removes `tools`. Slot updates apply only while a slot with the task's
|
|
2991
|
+
`taskId` exists; without one, the durable partial output, details, and
|
|
2992
|
+
diagnostics are empty.
|
|
2993
|
+
|
|
2994
|
+
A compaction's status is added in the commit that creates the task and removed
|
|
2995
|
+
in the commit that decides its outcome: the task's own outcome commit, even when
|
|
2996
|
+
that outcome holds as `completing` (section 5.5), or the scheduler's cleanup
|
|
2997
|
+
(section 5.4).
|
|
2998
|
+
It stays small: the summary text lives in the task's placement, never here. Partials are
|
|
2999
|
+
normalized to strict JSON before assignment. Every terminal path of a run task
|
|
3000
|
+
removes `run`, `generation`, and `tools` in the commit that settles the run's
|
|
3001
|
+
inputs. `tx.settleSubmission()` stages each input's new status
|
|
2437
3002
|
and resolves the transaction's latest candidate submission record, falling back
|
|
2438
3003
|
to committed state, during assembly, like task-document validation (section 3.3),
|
|
2439
3004
|
so it is not a caller table read and works after the commit's first table write.
|
|
@@ -2443,18 +3008,44 @@ so it is not a caller table read and works after the commit's first table write.
|
|
|
2443
3008
|
```ts
|
|
2444
3009
|
type GenerationInput = {};
|
|
2445
3010
|
type GenerationCheckpoint =
|
|
2446
|
-
| {
|
|
3011
|
+
| {
|
|
3012
|
+
phase: "prepare";
|
|
3013
|
+
attempt: number;
|
|
3014
|
+
/** The blocking compaction this generation waited for; it starts no other compaction. */
|
|
3015
|
+
compacted?: TaskId<CompactionResult>;
|
|
3016
|
+
/** Error text of the overflow that started `compacted`; checked once when `prepare` resumes. */
|
|
3017
|
+
overflow?: string;
|
|
3018
|
+
}
|
|
2447
3019
|
| {
|
|
2448
3020
|
phase: "request";
|
|
2449
3021
|
attempt: number;
|
|
3022
|
+
compacted?: TaskId<CompactionResult>;
|
|
2450
3023
|
model: ModelRef;
|
|
2451
3024
|
thinkingLevel: ModelThinkingLevel;
|
|
2452
3025
|
streamOptions: ConversationStreamOptions;
|
|
3026
|
+
toolExecution: ToolExecutionMode;
|
|
2453
3027
|
/** Newest entry included in the request. */
|
|
2454
3028
|
cutoff: EntryId;
|
|
2455
3029
|
}
|
|
2456
|
-
| { phase: "retry"; attempt: number; until: number }
|
|
2457
|
-
| {
|
|
3030
|
+
| { phase: "retry"; attempt: number; compacted?: TaskId<CompactionResult>; until: number }
|
|
3031
|
+
| {
|
|
3032
|
+
phase: "poll";
|
|
3033
|
+
attempt: number;
|
|
3034
|
+
compacted?: TaskId<CompactionResult>;
|
|
3035
|
+
model: ModelRef;
|
|
3036
|
+
toolExecution: ToolExecutionMode;
|
|
3037
|
+
cutoff: EntryId;
|
|
3038
|
+
handle: DeferredHandle;
|
|
3039
|
+
pollAt: number;
|
|
3040
|
+
}
|
|
3041
|
+
| {
|
|
3042
|
+
/** Waiting on the round's tool tasks; `waiting` holds this checkpoint (section 8.5). */
|
|
3043
|
+
phase: "tools";
|
|
3044
|
+
assistant: EntryId;
|
|
3045
|
+
tools: TaskId<ToolTaskResult>[];
|
|
3046
|
+
/** Calls of a sequential round not started yet, in call order. */
|
|
3047
|
+
pending: string[];
|
|
3048
|
+
};
|
|
2458
3049
|
type GenerationResult = { entryId: EntryId };
|
|
2459
3050
|
```
|
|
2460
3051
|
|
|
@@ -2463,9 +3054,35 @@ The run's inputs live in `pi.live.run`, not in the task input.
|
|
|
2463
3054
|
|
|
2464
3055
|
- `prepare` runs section 7.4 against the committed configuration. When no model
|
|
2465
3056
|
is configured or `models.getModel()` does not know it, the task fails with
|
|
2466
|
-
`no_model`.
|
|
3057
|
+
`no_model`. When it resumes with `overflow` and its compaction did not
|
|
3058
|
+
complete with an `entryId`, the run fails with `model_error` and the overflow
|
|
3059
|
+
text as detail. Otherwise one commit appends the planned `pi.system` entries and
|
|
2467
3060
|
moves to `request` with the new tail as `cutoff` and the configuration's model,
|
|
2468
|
-
thinking level, and
|
|
3061
|
+
thinking level, stream options, and tool execution mode. These stay fixed for
|
|
3062
|
+
this request attempt; a retry prepares again. `compacted` carries over to
|
|
3063
|
+
`request`, `retry`, `poll`, and the next `prepare`.
|
|
3064
|
+
- Before that commit, `prepare` checks the compaction thresholds (section 8.7)
|
|
3065
|
+
when the conversation's compaction policy is enabled, the model's
|
|
3066
|
+
`contextWindow` is positive, and `compacted` is absent. The estimate starts at
|
|
3067
|
+
the newest assistant message in the committed model context whose entry was
|
|
3068
|
+
appended after the head marker (all qualify without one) and whose usage is
|
|
3069
|
+
nonzero: pi-ai `calculateContextTokens()` of its usage, plus pi-ai
|
|
3070
|
+
`estimateMessageTokens()` of every context message after it and of the planned
|
|
3071
|
+
system messages. Its request included the marker, because a head is placed
|
|
3072
|
+
only while no request is in flight. Without such a message, every message is
|
|
3073
|
+
estimated. Both thresholds apply only when range selection finds a cut.
|
|
3074
|
+
- Above `contextWindow - reserveTokens`, the blocking threshold: instead of
|
|
3075
|
+
appending, one commit creates a compaction owned by the generation with reason
|
|
3076
|
+
`threshold`, adds its status, and commits `waiting` on it with `allSettled`
|
|
3077
|
+
and checkpoint `{ phase: "prepare", attempt, compacted }`. Whatever its
|
|
3078
|
+
outcome, `prepare` then runs again and sends the request.
|
|
3079
|
+
- Above `contextWindow - reserveTokens - backgroundTokens` with
|
|
3080
|
+
`backgroundTokens > 0` and no compaction status in `pi.live`: the commit that
|
|
3081
|
+
moves to `request` also creates a background compaction, owned by the
|
|
3082
|
+
conversation, with reason `threshold`, and adds its status. The generation
|
|
3083
|
+
does not wait for it. The retry policy is read when the
|
|
3084
|
+
attempt's result is classified, because it governs the next attempt (section
|
|
3085
|
+
2.2).
|
|
2469
3086
|
- `request` and `poll` resolve the checkpoint's model through
|
|
2470
3087
|
`models.getModel()`; an unknown model fails the task with `no_model`, like
|
|
2471
3088
|
`prepare`. `request` converts a leftover partial (below) before it resolves
|
|
@@ -2474,15 +3091,37 @@ The run's inputs live in `pi.live.run`, not in the task input.
|
|
|
2474
3091
|
interrupted attempt into an aborted `pi.assistant` entry. It then streams the
|
|
2475
3092
|
model context through `cutoff` with the invocation signal, the thinking level
|
|
2476
3093
|
as `reasoning` (omitted for `off`), and the pinned `streamOptions`,
|
|
2477
|
-
committing throttled partials.
|
|
2478
|
-
|
|
3094
|
+
committing throttled partials. Before streaming, the `beforeRequest` chain may
|
|
3095
|
+
replace the messages for this request only. Recovery resends the same
|
|
3096
|
+
committed messages with the same pinned model, thinking level, and stream
|
|
3097
|
+
options, and reruns `beforeRequest`.
|
|
3098
|
+
- `afterResponse` observes every terminal message, from `request` or `poll`,
|
|
3099
|
+
before classification; a still deferred result is not terminal.
|
|
2479
3100
|
- Before classifying, the handler stops the partial throttle and awaits any
|
|
2480
3101
|
partial commit in flight, so no stale partial lands after the outcome. The
|
|
2481
3102
|
terminal message is classified in one commit that also clears the partial:
|
|
2482
|
-
- `stop`/`length`:
|
|
2483
|
-
`
|
|
2484
|
-
|
|
2485
|
-
|
|
3103
|
+
- `stop`/`length`: before the commit, the `onYield` chain runs; the first
|
|
3104
|
+
`{ continue }` wins. The commit appends the answer and applies the final
|
|
3105
|
+
boundary (section 6). With a continuation and no selected user item or
|
|
3106
|
+
reset, it appends a `pi.user` entry with the continuation content, creates a
|
|
3107
|
+
successor generation, and hands it `pi.live.run`, keeping the inputs open.
|
|
3108
|
+
Otherwise it settles the run's inputs `done`, removes `run` and
|
|
3109
|
+
`generation`, and starts a successor run for the selected user items, if
|
|
3110
|
+
any; a dropped continuation is not retried. Both complete with
|
|
3111
|
+
`{ entryId }`.
|
|
3112
|
+
- `toolUse` with at least one tool call: the commit appends the assistant
|
|
3113
|
+
entry and starts the tool round described below, moving to `waiting` in the
|
|
3114
|
+
`tools` phase. A `toolUse` message without calls is classified like `stop`.
|
|
3115
|
+
- `error` that pi-ai `isContextOverflow()` recognizes, while the compaction
|
|
3116
|
+
policy is enabled, `compacted` is absent, and range selection finds a cut:
|
|
3117
|
+
the commit appends the error entry, removes `generation`, creates a
|
|
3118
|
+
compaction owned by the generation with reason `overflow`, adds its status,
|
|
3119
|
+
and commits `waiting` on it with `allSettled` and checkpoint `{ phase:
|
|
3120
|
+
"prepare", attempt, compacted, overflow }`; the recovery request does not
|
|
3121
|
+
count against the retry policy. Any other overflow,
|
|
3122
|
+
including a second one, is never retried and fails like the non-retryable
|
|
3123
|
+
errors below. A `stop` or `length` response is never classified as overflow;
|
|
3124
|
+
the next threshold check handles silent overflow.
|
|
2486
3125
|
- `error` that `isRetryableAssistantError()` accepts while the conversation's
|
|
2487
3126
|
retry policy allows another attempt (`enabled` and `attempt <= maxRetries`,
|
|
2488
3127
|
so `maxRetries` counts retries after the first attempt): append the error entry and move to
|
|
@@ -2500,10 +3139,294 @@ The run's inputs live in `pi.live.run`, not in the task input.
|
|
|
2500
3139
|
known (a failure is reported), converts a committed partial, settles the inputs `unanswered` with
|
|
2501
3140
|
`aborted`, removes `run` and `generation`, and ends `aborted`.
|
|
2502
3141
|
|
|
3142
|
+
A tool round starts in the commit that appends the tool-calling answer:
|
|
3143
|
+
|
|
3144
|
+
1. The offered tools are replayed with pi-ai `getCurrentTools()` from the
|
|
3145
|
+
committed model context through `cutoff`: `request` already holds it, and
|
|
3146
|
+
`poll` derives it again. A call to a tool not offered gets its
|
|
3147
|
+
`tool_unavailable` result entry here, without a task.
|
|
3148
|
+
2. Every other call gets a `pi.tool` task owned by the generation, with input
|
|
3149
|
+
`{ assistant, callId }`. The round is sequential when the pinned
|
|
3150
|
+
`toolExecution` is `sequential` or any called tool's registration in the phase
|
|
3151
|
+
snapshot has `executionMode: "sequential"`: then only the first call gets its
|
|
3152
|
+
task now and the rest wait in `pending`. Otherwise every call gets its task at
|
|
3153
|
+
once and they run in parallel. The checkpoint's `tools` lists the tasks
|
|
3154
|
+
created so far and grows by one per started sequential call, while
|
|
3155
|
+
`pending` shrinks.
|
|
3156
|
+
3. The generation commits `waiting` on its tool tasks with `allSettled` and the
|
|
3157
|
+
`tools` checkpoint (section 8.5); `pi.live.run` stays with it.
|
|
3158
|
+
4. `pi.live.tools` receives the round's slots (section 8.2), and `generation` is
|
|
3159
|
+
removed.
|
|
3160
|
+
|
|
2503
3161
|
Input submissions settle `unanswered` with one of these reasons: `no_model`,
|
|
2504
3162
|
`model_error` (detail: provider error text), `aborted`, `faulted` (detail: error
|
|
2505
3163
|
message), or an orphaning blocked reason (section 5.4). Fault and orphan
|
|
2506
|
-
settlement
|
|
3164
|
+
settlement convert a committed partial into an aborted `pi.assistant` entry,
|
|
3165
|
+
like the abort handler.
|
|
3166
|
+
|
|
3167
|
+
### 8.4 Tool
|
|
3168
|
+
|
|
3169
|
+
```ts
|
|
3170
|
+
type ToolTaskInput = { assistant: EntryId; callId: string };
|
|
3171
|
+
type ToolTaskCheckpoint =
|
|
3172
|
+
| { phase: "call" }
|
|
3173
|
+
| { phase: "execute"; arguments: JsonObject; replay: "safe" | "unsafe" };
|
|
3174
|
+
type ToolTaskResult = { entryId: EntryId; control?: ToolControl };
|
|
3175
|
+
```
|
|
3176
|
+
|
|
3177
|
+
`pi.tool` is version 1 and starts at `{ phase: "call" }`. The input stays small
|
|
3178
|
+
because the terminal record keeps it; the call is read from the assistant entry.
|
|
3179
|
+
|
|
3180
|
+
- `call` reads the call with `runtime.entry()`, resolves the composed tool from
|
|
3181
|
+
its phase snapshot, validates the arguments with pi-ai
|
|
3182
|
+
`validateToolArguments()`, runs the `beforeTool` chain, and validates again
|
|
3183
|
+
(section 7.3). One commit then records intent: it moves to `execute` with the
|
|
3184
|
+
final arguments and the tool's replay policy and sets the slot `running`. The
|
|
3185
|
+
same handler executes the tool, runs the `afterTool` chain, and commits the
|
|
3186
|
+
result. An unregistered tool, invalid arguments, or a block commits the
|
|
3187
|
+
corresponding error result instead, without intent.
|
|
3188
|
+
- `execute` is reached only by recovery. When both the stored and the currently
|
|
3189
|
+
registered policy are `safe`, it executes again with the stored arguments,
|
|
3190
|
+
without `beforeTool`, and settles like `call`. Otherwise it commits an
|
|
3191
|
+
`interrupted` error result from the slot's durable partial output, details, and
|
|
3192
|
+
diagnostics, and ends `failed` with `{ entryId }`.
|
|
3193
|
+
- The result commit bounds the content, appends the diagnostics block (section
|
|
3194
|
+
7.3), appends one `pi.tool-result` entry with `model: [{ role: "toolResult",
|
|
3195
|
+
toolCallId, toolName, content, details, isError, timestamp }]` and
|
|
3196
|
+
`data: { diagnostics }`, marks the slot `done`, and
|
|
3197
|
+
completes with `{ entryId, control }`. An `isError` result still completes.
|
|
3198
|
+
- A throw from `execute()` becomes a `tool_error` result and the task ends
|
|
3199
|
+
`failed` with `{ entryId }`; once the invocation is signalled it propagates
|
|
3200
|
+
instead. Ending `failed`, like `aborted`, records cancellation intent (section
|
|
3201
|
+
5.4), so conversations the call owns, which nothing supervises any more, are
|
|
3202
|
+
aborted. A returned `isError` result still completes, and its call's owned work
|
|
3203
|
+
survives. Either way the run continues with the result entry. A tool task whose
|
|
3204
|
+
owned conversations still have ordinary work when it commits its result holds
|
|
3205
|
+
`completing` with the result entry already written (section 5.5); the
|
|
3206
|
+
generation resumes only when the tool task is terminal.
|
|
3207
|
+
- The abort handler commits an `aborted` error result from the slot's durable
|
|
3208
|
+
partial output, details, and diagnostics and ends `aborted` with `{ entryId }`.
|
|
3209
|
+
|
|
3210
|
+
### 8.5 Tool rounds
|
|
3211
|
+
|
|
3212
|
+
The generation resumes in its `tools` phase once every tool task it waits on is
|
|
3213
|
+
terminal. In a sequential round with calls left in `pending`, one commit creates
|
|
3214
|
+
the next call's owned tool task and waits on it again with the shorter `pending`.
|
|
3215
|
+
Otherwise it reads the tool records with `runtime.getTask()` and the round's
|
|
3216
|
+
result entries from the `pi.live.tools` slots, runs the `afterTools` observers,
|
|
3217
|
+
and then commits once:
|
|
3218
|
+
|
|
3219
|
+
- It appends every `addTools` name not already active to the configured
|
|
3220
|
+
loadout; the next preparation offers it.
|
|
3221
|
+
- When every result of the round requests `terminate`, or any requests
|
|
3222
|
+
`handoff`, it appends the handoff's `pi.reset` entry, if any, settles the
|
|
3223
|
+
run's inputs `done` with the tool-calling answer, removes `run` and `tools`,
|
|
3224
|
+
and applies the final boundary.
|
|
3225
|
+
- Otherwise it removes `tools` and applies the `postTools` boundary. When that
|
|
3226
|
+
boundary placed a reset, the run's inputs settle `unanswered` with `reset` and
|
|
3227
|
+
selected user items start a successor run (section 6). Otherwise selected
|
|
3228
|
+
steer IDs join `pi.live.run`, and it creates the next generation, owned by the
|
|
3229
|
+
conversation, and hands it the run.
|
|
3230
|
+
|
|
3231
|
+
It completes with `{ entryId }` of the tool-calling answer. Its abort handler
|
|
3232
|
+
runs only after its tool tasks are terminal (section 5.5). For every call still
|
|
3233
|
+
in `pending`, which never started, it appends an `aborted` error result; a
|
|
3234
|
+
started call whose task faulted or was orphaned keeps
|
|
3235
|
+
its `done` slot without an entry (section 8.2). It then settles the inputs
|
|
3236
|
+
`unanswered` with `aborted`, removes `run`, `generation`, and `tools`, and ends
|
|
3237
|
+
`aborted`. `abortTask()` on the generation therefore aborts its whole round.
|
|
3238
|
+
|
|
3239
|
+
### 8.6 Usage
|
|
3240
|
+
|
|
3241
|
+
```ts
|
|
3242
|
+
type UsageState = {
|
|
3243
|
+
/** Assistant entries and compaction summarization attempts, keyed `provider/modelId`. */
|
|
3244
|
+
models: Record<string, Usage>;
|
|
3245
|
+
/** Tool results, keyed by tool name; their usage has no model identity. */
|
|
3246
|
+
tools: Record<string, Usage>;
|
|
3247
|
+
};
|
|
3248
|
+
```
|
|
3249
|
+
|
|
3250
|
+
| field | value |
|
|
3251
|
+
|---|---|
|
|
3252
|
+
| kind | `pi.usage` |
|
|
3253
|
+
| version | `1` |
|
|
3254
|
+
| scope/history/fork | conversation, `latest`, `initial` |
|
|
3255
|
+
| `initial()` | `{ models: {}, tools: {} }` |
|
|
3256
|
+
| checkpoint | complete base on every change |
|
|
3257
|
+
| view mount | `docs["pi.usage"]` |
|
|
3258
|
+
| created | with every Harness conversation (section 2.2) |
|
|
3259
|
+
|
|
3260
|
+
`pi.usage` is the ledger of the conversation's own spend. Every built-in writer
|
|
3261
|
+
of a `pi.assistant` entry adds its message's `usage` to `models` under the
|
|
3262
|
+
message's own `provider/model`, and every writer of
|
|
3263
|
+
a `pi.tool-result` entry with `usage` adds it to `tools`, in the same commit.
|
|
3264
|
+
Every summarization attempt of a compaction task adds its response's `usage` to
|
|
3265
|
+
`models` in the commit that classifies it (section 8.7); that spend has no entry,
|
|
3266
|
+
whether the summary is placed, fails, or ends stale. Failed and aborted attempts
|
|
3267
|
+
count. A fork starts at zero, so no spend is counted
|
|
3268
|
+
twice. `Harness.usage()` sums every conversation's document into the Session
|
|
3269
|
+
total; other totals, such as an ownership subtree, are application sums over
|
|
3270
|
+
`scanConversations()`. Nothing stores a total across conversations.
|
|
3271
|
+
|
|
3272
|
+
### 8.7 Compaction
|
|
3273
|
+
|
|
3274
|
+
```ts
|
|
3275
|
+
type CompactionReason = "manual" | "threshold" | "overflow";
|
|
3276
|
+
type CompactionInput = { reason: CompactionReason; instructions?: string };
|
|
3277
|
+
/** The pinned summarization request. */
|
|
3278
|
+
type SummaryRequest = {
|
|
3279
|
+
attempt: number;
|
|
3280
|
+
model: ModelRef;
|
|
3281
|
+
thinkingLevel: ModelThinkingLevel;
|
|
3282
|
+
streamOptions: ConversationStreamOptions;
|
|
3283
|
+
maxTokens: number;
|
|
3284
|
+
/** Newest entry of the context the range was selected from. */
|
|
3285
|
+
tail: EntryId;
|
|
3286
|
+
/** First entry kept verbatim; the summary's `head`. */
|
|
3287
|
+
firstKept: EntryId;
|
|
3288
|
+
};
|
|
3289
|
+
type CompactionCheckpoint =
|
|
3290
|
+
| { phase: "select" }
|
|
3291
|
+
| ({ phase: "summarize" } & SummaryRequest)
|
|
3292
|
+
| ({ phase: "retry"; until: number } & SummaryRequest);
|
|
3293
|
+
/**
|
|
3294
|
+
* `entryId` of a blocking compaction's summary, or the `submissionId` of a conversation-owned compaction's summary
|
|
3295
|
+
* write; both absent when nothing was compacted.
|
|
3296
|
+
*/
|
|
3297
|
+
type CompactionResult = { entryId?: EntryId; submissionId?: SubmissionId };
|
|
3298
|
+
```
|
|
3299
|
+
|
|
3300
|
+
`pi.compaction` is version 1 and starts at `{ phase: "select" }`. Compaction
|
|
3301
|
+
replaces an old prefix of the model context with a summary entry whose `head` is
|
|
3302
|
+
the first kept entry (section 2.1). Raw history stays in storage. There are three
|
|
3303
|
+
ways to start one; the task is the same, only ownership and placement differ:
|
|
3304
|
+
|
|
3305
|
+
| started by | owner | background | generation waits | placement |
|
|
3306
|
+
|---|---|---|---|---|
|
|
3307
|
+
| `Conversation.compact()` | conversation | no | no | write submission |
|
|
3308
|
+
| generation above the background threshold | conversation | yes | no | write submission |
|
|
3309
|
+
| generation above the blocking threshold, or on overflow | the generation | no | yes | direct append |
|
|
3310
|
+
|
|
3311
|
+
Only a blocking compaction runs while the conversation's run waits for it. The
|
|
3312
|
+
others never take run control: the conversation keeps working while they
|
|
3313
|
+
summarize, and their summary is placed like any passive write. Compactions do
|
|
3314
|
+
not coordinate with each other; several may run at once, and section 6 decides
|
|
3315
|
+
between their summaries.
|
|
3316
|
+
|
|
3317
|
+
**Range selection** is a pure function of a `ContextView`, whose
|
|
3318
|
+
`contributions` give each active entry's model messages after every edit in the
|
|
3319
|
+
range (section 2.1, rules 4 and 9), including edits carried by older in-range
|
|
3320
|
+
markers, and `keepRecentTokens`.
|
|
3321
|
+
|
|
3322
|
+
1. Cut candidates are the non-marker entries whose contribution begins with a
|
|
3323
|
+
user or assistant message. Tool results and system entries are never
|
|
3324
|
+
candidates, so a kept assistant message keeps its tool results. A user entry
|
|
3325
|
+
is not a candidate either while a result for a call of the assistant before
|
|
3326
|
+
it follows it, before the next assistant (section 2.1, rule 7).
|
|
3327
|
+
2. Walk the non-marker entries from newest to oldest, adding pi-ai
|
|
3328
|
+
`estimateMessageTokens()` of each contribution. At the first entry where the
|
|
3329
|
+
sum reaches `keepRecentTokens`, the cut is the first candidate at or after
|
|
3330
|
+
that entry in transcript order, or the newest candidate when none follows.
|
|
3331
|
+
3. There is nothing to compact when the sum never reaches `keepRecentTokens`, or
|
|
3332
|
+
when no non-marker entry before the cut contributes a model message.
|
|
3333
|
+
|
|
3334
|
+
The summarized entries are the head marker, if any, followed by the non-marker
|
|
3335
|
+
entries before the cut. Their messages are their contributions, ordered as in
|
|
3336
|
+
section 2.1, rules 7 and 8, so an earlier summary is summarized again together
|
|
3337
|
+
with the history after it. Nothing is compacted while the context is smaller
|
|
3338
|
+
than `keepRecentTokens`, so with a window where `contextWindow - reserveTokens`
|
|
3339
|
+
is below it, overflow may come before any threshold compaction.
|
|
3340
|
+
|
|
3341
|
+
```text
|
|
3342
|
+
1 user 2 assistant (read) 3 tool result, 30k tokens 4 assistant 5 user 6 assistant
|
|
3343
|
+
keepRecentTokens 20k: the walk from 6 reaches 20k at 3; the first candidate at or after 3 is 4.
|
|
3344
|
+
The summary covers 1-3; the model context becomes [summary, 4, 5, 6].
|
|
3345
|
+
```
|
|
3346
|
+
|
|
3347
|
+
Phases:
|
|
3348
|
+
|
|
3349
|
+
- `select` reads the committed configuration and captures the committed
|
|
3350
|
+
context. When no model is configured or `models.getModel()` does not know it,
|
|
3351
|
+
the task fails with `no_model`. With nothing to compact it completes with
|
|
3352
|
+
`{}`. Otherwise the `beforeCompact` hooks (section 7.2) run with the
|
|
3353
|
+
summarized entries and messages; the first decision wins. `{ decline: true }` completes
|
|
3354
|
+
with `{}`, and `{ summary }` is placed as below in the same commit. Without a
|
|
3355
|
+
decision, one commit moves to `summarize` with attempt 1, the configured model,
|
|
3356
|
+
thinking level, and stream options, `maxTokens` = `min(floor(0.8 *
|
|
3357
|
+
reserveTokens), model.maxTokens)` (the model's value only when positive), the
|
|
3358
|
+
captured tail, and the cut as `firstKept`.
|
|
3359
|
+
- `summarize` derives the summarized messages again from the context at `tail`,
|
|
3360
|
+
which is immutable, and serializes them to text: `[User]: ...`,
|
|
3361
|
+
`[Assistant thinking]: ...`, `[Assistant]: ...`,
|
|
3362
|
+
`[Assistant tool calls]: name(key=json, ...)`, and `[Tool result]: ...`
|
|
3363
|
+
truncated to 2000 characters; system messages are omitted. The request has no
|
|
3364
|
+
tools and two messages: a system message with the built-in summarization
|
|
3365
|
+
system prompt, and a user message with the serialized text in
|
|
3366
|
+
`<conversation>` tags followed by the built-in summarization prompt, and
|
|
3367
|
+
`Additional focus: <instructions>` when the input has instructions. It uses the
|
|
3368
|
+
pinned thinking level as `reasoning`, and the pinned stream options without
|
|
3369
|
+
`deferred`, with `cacheRetention: "none"` and the pinned `maxTokens`.
|
|
3370
|
+
Recovery resends the same request. The response is classified in one commit
|
|
3371
|
+
that adds its usage to `pi.usage` (section 8.6):
|
|
3372
|
+
- `stop` with non-empty text and no tool call: the text is the summary, placed
|
|
3373
|
+
as below.
|
|
3374
|
+
- `error` that `isRetryableAssistantError()` accepts while the conversation's
|
|
3375
|
+
retry policy allows another attempt: move to `retry` with `until = now +
|
|
3376
|
+
retryDelayMs(policy, attempt)`, and set the status's `attempt` and `retry`.
|
|
3377
|
+
- anything else, including a `length` stop, which leaves the summary
|
|
3378
|
+
incomplete: fail with `model_error`.
|
|
3379
|
+
- `retry` sleeps until `until`, then returns to `summarize` with the next attempt
|
|
3380
|
+
and the same pinned request.
|
|
3381
|
+
|
|
3382
|
+
The built-in prompts are the coding agent's structured checkpoint format. The
|
|
3383
|
+
prompt also asks the model to carry forward an earlier summary at the start of
|
|
3384
|
+
the conversation.
|
|
3385
|
+
|
|
3386
|
+
**Placement.** The summary entry is
|
|
3387
|
+
|
|
3388
|
+
```ts
|
|
3389
|
+
{
|
|
3390
|
+
kind: "pi.compaction",
|
|
3391
|
+
head: firstKept,
|
|
3392
|
+
model: [{ role: "user", content: [{ type: "text", text: wrapped }], timestamp: now }],
|
|
3393
|
+
data: { reason },
|
|
3394
|
+
}
|
|
3395
|
+
```
|
|
3396
|
+
|
|
3397
|
+
where `wrapped` is `"The conversation history before this point was compacted
|
|
3398
|
+
into the following summary:\n\n<summary>\n"`, the summary, and
|
|
3399
|
+
`"\n</summary>"`. The commit that places it also removes the task's status and
|
|
3400
|
+
commits the task's `completed` outcome:
|
|
3401
|
+
|
|
3402
|
+
- A blocking compaction appends the entry directly and completes with
|
|
3403
|
+
`{ entryId }`. Its generation holds the run and waits, so nothing else writes
|
|
3404
|
+
the conversation.
|
|
3405
|
+
- A conversation-owned compaction admits the entry as a write submission with
|
|
3406
|
+
request ID `compaction:<taskId>`, following section 6 exactly: in an idle
|
|
3407
|
+
conversation with an empty inbox it is appended at once or settles `stale`;
|
|
3408
|
+
in an idle conversation with queued items it queues and a final boundary runs;
|
|
3409
|
+
in a busy one it queues for the next boundary. It completes with
|
|
3410
|
+
`{ submissionId }` and never waits for a queued placement;
|
|
3411
|
+
`Harness.submission()` observes the placement.
|
|
3412
|
+
|
|
3413
|
+
The next generation's preparation finds a head marker without a later
|
|
3414
|
+
`pi.system` entry and writes a complete system baseline (section 7.4).
|
|
3415
|
+
|
|
3416
|
+
**Cancellation.** The abort handler removes the task's status and ends
|
|
3417
|
+
`aborted`; it writes no entry. An attempt cut short by abort or a crash has no
|
|
3418
|
+
committed response, so its usage is not counted. A manual compaction is
|
|
3419
|
+
ordinary work of its conversation: `Conversation.abort()` aborts it. A
|
|
3420
|
+
background compaction survives it and stops only through `abortTask()` or
|
|
3421
|
+
`Conversation.abort(context, { background: true })`. A blocking compaction is
|
|
3422
|
+
aborted with its generation, before the generation's abort handler runs
|
|
3423
|
+
(section 5.5). A summary that is already queued is a write submission:
|
|
3424
|
+
conversation abort keeps it, and `Submission.abort()` withdraws it.
|
|
3425
|
+
|
|
3426
|
+
When a blocking compaction ends without an `entryId`, because it found nothing
|
|
3427
|
+
to compact, was declined, failed, faulted, or was aborted directly, a threshold
|
|
3428
|
+
generation sends its request anyway and an overflow generation fails with
|
|
3429
|
+
`model_error` (section 8.3).
|
|
2507
3430
|
|
|
2508
3431
|
|
|
2509
3432
|
## 9. Document observation and Chord
|
|
@@ -2662,75 +3585,196 @@ rejection is observed.
|
|
|
2662
3585
|
|
|
2663
3586
|
### 9.3 Conversation view
|
|
2664
3587
|
|
|
2665
|
-
The public view is a fixed structural mount of
|
|
3588
|
+
The public view is a fixed structural mount of the conversation's active
|
|
3589
|
+
transcript and its built-in documents:
|
|
2666
3590
|
|
|
2667
3591
|
```ts
|
|
2668
3592
|
type ConversationView = {
|
|
2669
3593
|
readonly conversation: ConversationRecord;
|
|
3594
|
+
/** Raw active entries, as `ContextView.entries` (section 2.1): the head marker, then the non-head entries from its head. */
|
|
2670
3595
|
readonly entries: readonly EntryRecord[];
|
|
3596
|
+
/** `pi.conversation.config`, `pi.live`, `pi.inbox`, and `pi.usage`, keyed by kind; absent documents are absent. */
|
|
2671
3597
|
readonly docs: Readonly<Record<string, JsonObject>>;
|
|
2672
3598
|
};
|
|
2673
3599
|
```
|
|
2674
3600
|
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
through their own Chord services, not automatically mounted.
|
|
3601
|
+
These kinds and their fields are public protocol. Third-party documents are
|
|
3602
|
+
exposed through their own Chord services, not mounted.
|
|
2678
3603
|
|
|
2679
|
-
The
|
|
3604
|
+
The Harness keeps at most one mount per conversation. The first `viewState()`,
|
|
3605
|
+
`watch()`, or event attachment builds its revision on the Session line from the
|
|
3606
|
+
committed active transcript and documents; the mount is dropped when its last
|
|
3607
|
+
observer detaches. A mount derives each next revision from the Session's
|
|
3608
|
+
`subscribeCommits()` publications, like `watchDoc()`: every publication is
|
|
3609
|
+
already durable, so the view shows only committed state. One publication that
|
|
3610
|
+
touches the conversation yields one Chord batch:
|
|
2680
3611
|
|
|
2681
3612
|
```text
|
|
2682
3613
|
document op ["s", ["generation", "message"], value]
|
|
2683
3614
|
-> view op ["s", ["docs", "pi.live", "generation", "message"], value]
|
|
2684
3615
|
```
|
|
2685
3616
|
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
3617
|
+
An appended entry without a head is a splice at the end of `entries`. An
|
|
3618
|
+
appended head marker `H` makes the entries `H` followed by the current non-head
|
|
3619
|
+
entries at or after `H.head`: the kept entries stay, and splices remove the
|
|
3620
|
+
others and insert `H` at the front. Every kept entry is already mounted because
|
|
3621
|
+
Harness writers never target a head before the current range: resets and
|
|
3622
|
+
handoffs head themselves, compaction summaries target a kept entry of the
|
|
3623
|
+
current range, and head writes that reach further back are stale (section 6). A raw head write that targets further back shows only the
|
|
3624
|
+
mounted entries (section 12).
|
|
3625
|
+
Document creation and retirement set and delete the `docs` key. Parent entries
|
|
3626
|
+
through `parent.at` are immutable, so a child's mount follows only its own
|
|
3627
|
+
conversation. A publication that touches nothing mounted creates no revision; a
|
|
3628
|
+
redundant nonempty batch remains a real publication. Mounted document revisions
|
|
3629
|
+
are structurally shared under the trusted immutability contract. The mount
|
|
3630
|
+
performs no semantic projection and owns no persistence.
|
|
3631
|
+
|
|
3632
|
+
`Conversation.viewState()` exposes the mount as a disposable read-only Chord
|
|
3633
|
+
state. `Conversation.watch()` exposes the same mount through the serialized
|
|
3634
|
+
exact-frame watch of section 9.2, with bounded pending frames and full-value
|
|
3635
|
+
overflow replacements.
|
|
3636
|
+
|
|
3637
|
+
### 9.4 Agent events (experimental)
|
|
3638
|
+
|
|
3639
|
+
`watchEvents(harness, conversationId, context): Promise<AgentEventStream>`,
|
|
3640
|
+
exported from the package root, is an experimental adapter that
|
|
3641
|
+
translates one conversation's committed publications into agent events shaped
|
|
3642
|
+
like the coding agent's `AgentSessionEvent`s. It owns no tracker or persistence,
|
|
3643
|
+
covers exactly one conversation (not its owned subtree), and emits an event only
|
|
3644
|
+
after the commit that makes it true. Its protocol may change without notice.
|
|
3645
|
+
|
|
3646
|
+
```ts
|
|
3647
|
+
type AgentEvent =
|
|
3648
|
+
| {
|
|
3649
|
+
type: "snapshot";
|
|
3650
|
+
entries: readonly EntryRecord[];
|
|
3651
|
+
run?: { inputs: readonly SubmissionId[] };
|
|
3652
|
+
/** Current generation attempt: its in-flight partial, retry backoff, or deferred poll. */
|
|
3653
|
+
generation?: { attempt: number; message?: AssistantMessage; retry?: { at: number; error: string }; deferred?: { pollAt: number } };
|
|
3654
|
+
tools: readonly ToolSlot[];
|
|
3655
|
+
/** `pi.live.compactions` (section 8.2). */
|
|
3656
|
+
compactions: readonly CompactionStatus[];
|
|
3657
|
+
inbox: readonly { id: SubmissionId; mode: InboxItem["mode"] }[];
|
|
3658
|
+
config: ConversationConfigState;
|
|
3659
|
+
usage: UsageState;
|
|
3660
|
+
}
|
|
3661
|
+
| { type: "run_start"; inputs: readonly SubmissionId[] }
|
|
3662
|
+
| { type: "run_end"; inputs: readonly SubmissionId[] }
|
|
3663
|
+
| { type: "turn_start" }
|
|
3664
|
+
| { type: "turn_end" }
|
|
3665
|
+
| { type: "message_start"; message: Message }
|
|
3666
|
+
| { type: "message_update"; usage: Usage; changes: readonly MessageChange[] }
|
|
3667
|
+
| { type: "message_end"; entry: EntryRecord }
|
|
3668
|
+
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: JsonObject }
|
|
3669
|
+
| {
|
|
3670
|
+
type: "tool_execution_update";
|
|
3671
|
+
toolCallId: string;
|
|
3672
|
+
toolName: string;
|
|
3673
|
+
/** A front trim and then an append of the retained window, or its replacement. */
|
|
3674
|
+
output?: { trimStart?: number; append?: string } | { set: string };
|
|
3675
|
+
details?: JsonValue;
|
|
3676
|
+
diagnostics?: readonly ToolDiagnostic[];
|
|
3677
|
+
}
|
|
3678
|
+
/** `entry` is absent when the tool task faulted or was orphaned. */
|
|
3679
|
+
| { type: "tool_execution_end"; toolCallId: string; toolName: string; entry?: EntryRecord }
|
|
3680
|
+
| { type: "inbox_update"; items: readonly { id: SubmissionId; mode: InboxItem["mode"] }[] }
|
|
3681
|
+
| { type: "submission"; record: SubmissionRecord }
|
|
3682
|
+
| { type: "auto_retry_start"; attempt: number; at: number; errorMessage: string }
|
|
3683
|
+
| { type: "auto_retry_end"; attempt: number }
|
|
3684
|
+
| { type: "deferred_poll"; pollAt: number }
|
|
3685
|
+
| { type: "entry_appended"; entry: EntryRecord }
|
|
3686
|
+
| { type: "config_changed"; config: ConversationConfigState }
|
|
3687
|
+
| { type: "usage_changed"; usage: UsageState }
|
|
3688
|
+
| { type: "task_failed"; taskId: TaskId; kind: string; message: string }
|
|
3689
|
+
| { type: "compaction_start"; taskId: TaskId; reason: CompactionReason; blocking: boolean }
|
|
3690
|
+
/** The task's receipt tells whether it produced a summary; the summary entry has its own events. */
|
|
3691
|
+
| { type: "compaction_end"; taskId: TaskId; reason: CompactionReason };
|
|
3692
|
+
|
|
3693
|
+
/** One change to the in-flight assistant message, relative to that message. */
|
|
3694
|
+
type MessageChange =
|
|
3695
|
+
| { type: "text_start" | "thinking_start" | "toolcall_start"; contentIndex: number; block: AssistantMessage["content"][number] }
|
|
3696
|
+
| { type: "text_delta" | "thinking_delta"; contentIndex: number; delta: string }
|
|
3697
|
+
| { type: "toolcall_delta"; contentIndex: number; path: readonly (string | number)[]; delta: string }
|
|
3698
|
+
| { type: "block"; contentIndex: number; block: AssistantMessage["content"][number] }
|
|
3699
|
+
| { type: "message"; message: AssistantMessage };
|
|
3700
|
+
|
|
3701
|
+
interface AgentEventStream {
|
|
3702
|
+
/** The `snapshot` event at attachment. */
|
|
3703
|
+
readonly snapshot: Extract<AgentEvent, { type: "snapshot" }>;
|
|
3704
|
+
start(listener: (events: readonly AgentEvent[], context: Context) => Promise<void>): void;
|
|
3705
|
+
stop(): Promise<WatchEnd>;
|
|
3706
|
+
readonly closed: Promise<WatchEnd>;
|
|
3707
|
+
}
|
|
3708
|
+
```
|
|
3709
|
+
|
|
3710
|
+
Every event is relative to the `snapshot` and the events before it, never to
|
|
3711
|
+
Pico's document layout. `snapshot` replaces everything a consumer holds; every
|
|
3712
|
+
other event applies on top of it. Attachment captures the snapshot and
|
|
3713
|
+
registers for later publications atomically on the Session line.
|
|
3714
|
+
|
|
3715
|
+
Events derive from committed changes:
|
|
3716
|
+
|
|
3717
|
+
- `run_start`/`run_end`: `pi.live.run` appears, is removed, or is replaced by a
|
|
3718
|
+
successor run whose first input differs. Steers joining the current run and
|
|
3719
|
+
handovers between its tasks are not run events. The inputs' outcomes are
|
|
3720
|
+
`submission` events.
|
|
3721
|
+
- `turn_start`: a `pi.generation` task is created. `turn_end`: a generation's
|
|
3722
|
+
outcome is committed, when it holds `completing` or becomes terminal,
|
|
3723
|
+
whichever comes first, so a successor created at hold starts after it.
|
|
3724
|
+
- `message_start`: the first committed partial of an attempt, or, for a message
|
|
3725
|
+
entry without a committed partial, the entry itself; `message_end` follows for
|
|
3726
|
+
every entry with `model` messages. Generation commits a partial only once it has
|
|
3727
|
+
content, and every built-in path that clears one appends its entry, so each
|
|
3728
|
+
started message ends with its entry. Entries without messages are
|
|
3729
|
+
`entry_appended`.
|
|
3730
|
+
- `message_update`: the Chord operations on the committed partial, translated:
|
|
3731
|
+
an insertion into `content` starts blocks, an append to a block's `text` or
|
|
3732
|
+
`thinking` is a delta, an append to a string inside a tool call's `arguments`
|
|
3733
|
+
is a `toolcall_delta` with the path relative to `arguments`, any other change
|
|
3734
|
+
inside a block sends that `block`, which already holds the batch's later
|
|
3735
|
+
changes to it, and any other change to `content` sends the whole `message`.
|
|
3736
|
+
Each update carries the partial's current `usage`, as the coding agent's JSON
|
|
3737
|
+
mode does; a change of only `usage` sends no changes. Only deltas travel, so throttled
|
|
3738
|
+
partials cause no write amplification on the wire.
|
|
3739
|
+
- `tool_execution_start`: a slot becomes `running`; `args` come from the tool
|
|
3740
|
+
task's intent checkpoint in the same commit. A slot that becomes `done`
|
|
3741
|
+
without running (a call not offered, invalid arguments, a block, a fault
|
|
3742
|
+
before intent) gets only `tool_execution_end`. `tool_execution_update`: output
|
|
3743
|
+
appends, front trims, and replacements of the slot's retained window, and the
|
|
3744
|
+
slot's current `details` and `diagnostics` when they change; removed ones, as
|
|
3745
|
+
when a safe replay restarts the tool, send `null` and `[]`.
|
|
3746
|
+
`tool_execution_end`: the slot becomes `done`, with its `pi.tool-result`
|
|
3747
|
+
entry, which carries the diagnostics, or without one after a fault or
|
|
3748
|
+
orphan. An unfinished slot that disappears because its run ended ends with the
|
|
3749
|
+
result entry appended in the same commit, as for the unstarted calls of an
|
|
3750
|
+
aborted round, directly before its `message_start`, or without an entry.
|
|
3751
|
+
- `inbox_update`, `config_changed`, `usage_changed`: the document changed; a
|
|
3752
|
+
retired one reads as its initial value, as in a snapshot.
|
|
3753
|
+
- `auto_retry_start`/`auto_retry_end`, `deferred_poll`: `pi.live.generation`
|
|
3754
|
+
gains or drops `retry`, or gains `deferred` or moves its `pollAt`.
|
|
3755
|
+
- `task_failed`: a task of the conversation settles `faulted` or `orphaned`.
|
|
3756
|
+
- `compaction_start`/`compaction_end`: a status appears in or disappears from
|
|
3757
|
+
`pi.live.compactions`. A retry backoff shows only in the snapshot's
|
|
3758
|
+
`compactions`. A queued summary is placed later with its own
|
|
3759
|
+
`message_start`/`message_end` and `submission` events.
|
|
3760
|
+
|
|
3761
|
+
One commit produces one batch, in this order: `tool_execution_start`,
|
|
3762
|
+
`message_start` of a first partial, `message_update`, `tool_execution_update`,
|
|
3763
|
+
and retry/deferred events; then entries in append order with their
|
|
3764
|
+
`message_start`/`message_end` or `entry_appended`, where a tool result's
|
|
3765
|
+
`tool_execution_end` directly precedes its `message_start`, as in the coding
|
|
3766
|
+
agent; then the `tool_execution_end` of tools ending without an entry;
|
|
3767
|
+
then `compaction_end`, `task_failed`, `turn_end`, `run_end`; then `submission`
|
|
3768
|
+
events in ID order; then `inbox_update`, `config_changed`, and `usage_changed`;
|
|
3769
|
+
and last `compaction_start`, `run_start`, and `turn_start`. A stream buffers at most 100 undelivered
|
|
3770
|
+
batches; adding another replaces every undelivered batch with one `snapshot` of
|
|
3771
|
+
the newest committed state. The stream therefore converges but does not promise
|
|
3772
|
+
every transition; `Submission.wait()` reports exact outcomes. A reconnecting
|
|
3773
|
+
consumer attaches again and starts from its `snapshot`; nothing is replayed.
|
|
3774
|
+
Transport backpressure and disconnect policy belong to the consumer.
|
|
3775
|
+
|
|
3776
|
+
Print mode awaits its own input `Submission` and prints its answer. A TUI
|
|
3777
|
+
renders `ConversationView`; events may drive transient animation.
|
|
2734
3778
|
|
|
2735
3779
|
## 10. Storage contract
|
|
2736
3780
|
|
|
@@ -2758,7 +3802,7 @@ type EntryQuery = {
|
|
|
2758
3802
|
type TaskQuery = {
|
|
2759
3803
|
readonly conversationId?: ConversationId;
|
|
2760
3804
|
readonly kind?: string;
|
|
2761
|
-
readonly status?: "pending" | "running" | "terminal";
|
|
3805
|
+
readonly status?: "pending" | "running" | "waiting" | "completing" | "terminal";
|
|
2762
3806
|
readonly abortRequested?: boolean;
|
|
2763
3807
|
readonly background?: boolean;
|
|
2764
3808
|
};
|
|
@@ -3008,6 +4052,52 @@ These are contracts, not invitations to add defensive machinery:
|
|
|
3008
4052
|
with an active run. Raw entries appended by `Harness.commit()` or a custom
|
|
3009
4053
|
task while a generation prepares its request can misplace its system prompt entries;
|
|
3010
4054
|
use a write submission.
|
|
4055
|
+
- **Queued items after a failed run:** failure and task abort leave the inbox alone.
|
|
4056
|
+
Queued follow-ups wait in `pi.inbox`, and their `wait()` does not settle, until
|
|
4057
|
+
the next submission's boundary places them or the host withdraws them. A
|
|
4058
|
+
compaction summary is such a submission: when a manual or background
|
|
4059
|
+
compaction finishes in that idle conversation, its final boundary places the
|
|
4060
|
+
waiting follow-ups and starts a run.
|
|
4061
|
+
- **Compaction spend:** every manual and background compaction pays for one
|
|
4062
|
+
summarization request per attempt, also when its summary ends stale because a
|
|
4063
|
+
later compaction cut further.
|
|
4064
|
+
- **Heads and edits during compaction:** a summary reflects the context when its
|
|
4065
|
+
compaction selected the range. An application edit placed while it
|
|
4066
|
+
summarizes, such as a write replacing entry 20, is lost when the summary cuts
|
|
4067
|
+
past its target: the summary still describes the old entry 20, and the edit's
|
|
4068
|
+
target leaves the range. An application head placed meanwhile, for example
|
|
4069
|
+
one cutting at 85 to forget entries 10-84, is undone by a summary cutting at 90:
|
|
4070
|
+
that summary still describes 10-84. Place such writes before compacting.
|
|
4071
|
+
- **Summary timestamps:** a queued summary's user message carries the time its
|
|
4072
|
+
compaction finished, not its placement. Code that judges usage staleness by
|
|
4073
|
+
message timestamps, such as pi-ai `estimateContextTokens()` over view
|
|
4074
|
+
messages, can misjudge; the Harness estimate uses entry order (section 8.3).
|
|
4075
|
+
- **Raw head writes into the past:** a head written directly with `tx.appendEntry()`
|
|
4076
|
+
that targets an entry before the conversation's active range changes model
|
|
4077
|
+
context, but a mounted view keeps only the entries it already holds until the
|
|
4078
|
+
mount is rebuilt. Use a write submission, whose stale check rejects it.
|
|
4079
|
+
- **Double-counted subagent spend:** a tool that runs an owned conversation must
|
|
4080
|
+
not report that conversation's usage in its result; the child's `pi.usage`
|
|
4081
|
+
already counts it, and subtree sums would count it twice.
|
|
4082
|
+
- **Owned work holds its owner:** a task that owns live ordinary work stays
|
|
4083
|
+
`completing` until that work ends (section 5.5). Foreground work an extension
|
|
4084
|
+
starts in a subagent's conversation holds the calling tool, and with it the
|
|
4085
|
+
run; interrogating a subagent while its tool is completing extends the hold.
|
|
4086
|
+
Esc or a host timeout ends it; work that should not hold its owner is created
|
|
4087
|
+
conversation-owned and `background`.
|
|
4088
|
+
- **Work created by hooks:** hooks run inside the asking task's invocation, so
|
|
4089
|
+
work they create owned by that task holds it. Work that must delay a task's
|
|
4090
|
+
finish has to be created before the task commits its outcome; a listener
|
|
4091
|
+
reacting to published events afterwards cannot extend it. A run task's owned
|
|
4092
|
+
work must not write the transcript after the run was released.
|
|
4093
|
+
- **Compensation in abort handlers:** an abort handler cannot create owned
|
|
4094
|
+
children, because its task is abort-marked. Compensate at the level
|
|
4095
|
+
that did the effect, in that task's own abort handler, or inline, or through
|
|
4096
|
+
background conversation-owned tasks awaited with `waitForTask()`; a
|
|
4097
|
+
non-background one would be aborted by any cascade that reaches its
|
|
4098
|
+
conversation.
|
|
4099
|
+
- **Hook memo names:** hooks share the asking task's memo namespace with the
|
|
4100
|
+
task and other hooks. Prefix memo names.
|
|
3011
4101
|
- **Long transactions:** an async commit callback holds the Session mutation
|
|
3012
4102
|
line. Never await models, tools, processes, network calls, humans, a nested
|
|
3013
4103
|
Session commit, or a Session waiter inside it. Use methods on the current `Tx`.
|