@yaag/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +25 -0
- package/src/agent-names.ts +20 -0
- package/src/agent-usage.ts +72 -0
- package/src/agent.ts +130 -0
- package/src/args-validation.ts +11 -0
- package/src/ask-activity.ts +84 -0
- package/src/ask-contract-identity.ts +96 -0
- package/src/ask-exchange-events.ts +60 -0
- package/src/ask-exchange-options.ts +32 -0
- package/src/ask-exchange.ts +291 -0
- package/src/ask-hash.ts +86 -0
- package/src/ask-limit.ts +189 -0
- package/src/ask-output-steering.ts +69 -0
- package/src/ask-output-tail.ts +166 -0
- package/src/ask-output.ts +109 -0
- package/src/ask-settlement.ts +37 -0
- package/src/ask-turn.ts +70 -0
- package/src/cassette-loader.ts +131 -0
- package/src/cassette-publish.ts +55 -0
- package/src/cassette-replay.ts +178 -0
- package/src/cassette-schema.ts +152 -0
- package/src/cassette.ts +275 -0
- package/src/checkpoint-dir.ts +89 -0
- package/src/connection.ts +123 -0
- package/src/define-agent.ts +83 -0
- package/src/define-run.ts +69 -0
- package/src/errors.ts +115 -0
- package/src/events.ts +143 -0
- package/src/extension-package.ts +88 -0
- package/src/extension-paths.ts +66 -0
- package/src/extension-source.ts +60 -0
- package/src/fake-transport.ts +240 -0
- package/src/frame-gap.ts +41 -0
- package/src/frame-queue.ts +52 -0
- package/src/git-facts.ts +32 -0
- package/src/idle-watch.ts +154 -0
- package/src/index.ts +96 -0
- package/src/jsonl.ts +42 -0
- package/src/live-transport.ts +210 -0
- package/src/node-decoder-subagent.ts +67 -0
- package/src/node-decoder-workflow.ts +74 -0
- package/src/node-decoder.ts +23 -0
- package/src/node-decoders.ts +9 -0
- package/src/node-details.ts +70 -0
- package/src/node-path.ts +36 -0
- package/src/node-tracker.ts +143 -0
- package/src/pi-state.ts +108 -0
- package/src/prompt-gist.ts +13 -0
- package/src/prompt.ts +80 -0
- package/src/reap.ts +59 -0
- package/src/recording-transport.ts +97 -0
- package/src/replay-divergence.ts +155 -0
- package/src/replay-transport.ts +72 -0
- package/src/resume-preconditions.ts +59 -0
- package/src/resume-transport.ts +165 -0
- package/src/run-checkpoint.ts +93 -0
- package/src/run-context.ts +19 -0
- package/src/run.ts +274 -0
- package/src/skill-probe.ts +247 -0
- package/src/skill-restriction-transport.ts +76 -0
- package/src/spawn.ts +241 -0
- package/src/summary-agent.ts +310 -0
- package/src/summary-nodes.ts +77 -0
- package/src/summary.ts +213 -0
- package/src/tool-probe-extension.ts +17 -0
- package/src/tool-probe.ts +141 -0
- package/src/transport.ts +178 -0
- package/src/types.ts +130 -0
- package/src/validation-errors.ts +70 -0
- package/src/wire-constants.ts +24 -0
- package/src/worktree-transport.ts +125 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { YaagError } from "./errors.ts";
|
|
2
|
+
import type { AgentTransport, Frame } from "./transport.ts";
|
|
3
|
+
|
|
4
|
+
/** The useful part of a `response` frame, once narrowed. */
|
|
5
|
+
export interface CommandResponse {
|
|
6
|
+
readonly success: boolean;
|
|
7
|
+
readonly error: string | null;
|
|
8
|
+
readonly data: Record<string, unknown> | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Dialog methods block the Agent until answered (ADR-0012). */
|
|
12
|
+
const DIALOG_METHODS = new Set(["select", "confirm", "input", "editor"]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One conversation with an Agent over the transport seam: correlates commands
|
|
16
|
+
* with their responses by id, cancels dialogs, and hands every other frame to
|
|
17
|
+
* the Ask in flight.
|
|
18
|
+
*
|
|
19
|
+
* Lives above the seam, so it knows nothing about processes.
|
|
20
|
+
*/
|
|
21
|
+
export class Connection {
|
|
22
|
+
readonly #transport: AgentTransport;
|
|
23
|
+
readonly #agent: string;
|
|
24
|
+
readonly #pending = new Map<string, (response: CommandResponse) => void>();
|
|
25
|
+
readonly #closed: Promise<void>;
|
|
26
|
+
#observer: ((frame: Frame) => void) | null = null;
|
|
27
|
+
#dead = false;
|
|
28
|
+
#nextId = 0;
|
|
29
|
+
|
|
30
|
+
constructor(transport: AgentTransport, agent: string) {
|
|
31
|
+
this.#transport = transport;
|
|
32
|
+
this.#agent = agent;
|
|
33
|
+
this.#closed = this.#read();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True once the Agent's frame stream has ended: the Handle is permanently dead. */
|
|
37
|
+
get dead(): boolean {
|
|
38
|
+
return this.#dead;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Resolves when the Agent's frame stream ends. Never rejects. */
|
|
42
|
+
get closed(): Promise<void> {
|
|
43
|
+
return this.#closed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Routes non-response frames to a listener, replacing any previous one. */
|
|
47
|
+
observe(listener: ((frame: Frame) => void) | null): void {
|
|
48
|
+
this.#observer = listener;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sends one command and resolves with its id-correlated response.
|
|
53
|
+
* Rejects with AGENT_DIED if the Agent dies first.
|
|
54
|
+
*/
|
|
55
|
+
command(frame: Frame): Promise<CommandResponse> {
|
|
56
|
+
if (this.#dead) return Promise.reject(this.#deadError());
|
|
57
|
+
const id = `yaag-${this.#nextId++}`;
|
|
58
|
+
const response = new Promise<CommandResponse>((resolve) => {
|
|
59
|
+
this.#pending.set(id, resolve);
|
|
60
|
+
});
|
|
61
|
+
this.#transport.send({ ...frame, id });
|
|
62
|
+
// Responses can arrive after agent_settled, so nothing here waits on order.
|
|
63
|
+
return Promise.race([
|
|
64
|
+
response,
|
|
65
|
+
this.#closed.then(() => {
|
|
66
|
+
throw this.#deadError();
|
|
67
|
+
}),
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Never rejects: a broken stream is a dead Agent, not an unhandled error. */
|
|
72
|
+
async #read(): Promise<void> {
|
|
73
|
+
try {
|
|
74
|
+
for await (const frame of this.#transport.frames()) {
|
|
75
|
+
this.#dispatch(frame);
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
this.#dead = true;
|
|
79
|
+
this.#pending.clear();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#dispatch(frame: Frame): void {
|
|
84
|
+
if (frame.type === "response") {
|
|
85
|
+
const id = typeof frame.id === "string" ? frame.id : null;
|
|
86
|
+
const resolve = id === null ? undefined : this.#pending.get(id);
|
|
87
|
+
if (id !== null && resolve) {
|
|
88
|
+
this.#pending.delete(id);
|
|
89
|
+
resolve(toResponse(frame));
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (frame.type === "extension_ui_request") {
|
|
94
|
+
this.#answerDialog(frame);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.#observer?.(frame);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* An unanswered dialog wedges the Agent forever — the observed `confirm`
|
|
102
|
+
* carried no `timeout` field (ADR-0012). Cancelling is what Esc does.
|
|
103
|
+
*/
|
|
104
|
+
#answerDialog(frame: Frame): void {
|
|
105
|
+
const method = typeof frame.method === "string" ? frame.method : "";
|
|
106
|
+
if (!DIALOG_METHODS.has(method)) return;
|
|
107
|
+
if (typeof frame.id !== "string") return;
|
|
108
|
+
this.#transport.send({ type: "extension_ui_response", id: frame.id, cancelled: true });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#deadError(): YaagError {
|
|
112
|
+
return new YaagError("AGENT_DIED", `agent "${this.#agent}" is no longer running`, this.#agent);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toResponse(frame: Frame): CommandResponse {
|
|
117
|
+
const data: unknown = frame.data;
|
|
118
|
+
return {
|
|
119
|
+
success: frame.success === true,
|
|
120
|
+
error: typeof frame.error === "string" ? frame.error : null,
|
|
121
|
+
data: typeof data === "object" && data !== null ? { ...data } : null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { AskOptions, ThinkingLevel } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export type { ThinkingLevel } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/** What `defineAgent` is given: the policy of one Agent, never its topology. */
|
|
6
|
+
export interface AgentConfig {
|
|
7
|
+
/** Stable identity, used in every Lifecycle Event and log line. */
|
|
8
|
+
readonly name: string;
|
|
9
|
+
/** System prompt text appended to pi's own, unless `overrideSystemPrompt`. */
|
|
10
|
+
readonly prompt?: string;
|
|
11
|
+
/** Replace pi's system prompt with `prompt` instead of appending to it. */
|
|
12
|
+
readonly overrideSystemPrompt?: boolean;
|
|
13
|
+
/** Model id handed to `pi --model`. Unset = pi's default. */
|
|
14
|
+
readonly model?: string;
|
|
15
|
+
/** Thinking budget for the Agent's turns. */
|
|
16
|
+
readonly thinking?: ThinkingLevel;
|
|
17
|
+
/** Allow-list of tool names. Unset = pi's default tool set. */
|
|
18
|
+
readonly tools?: readonly string[];
|
|
19
|
+
/** Deny-list of tool names, applied after `tools`. */
|
|
20
|
+
readonly disallowedTools?: readonly string[];
|
|
21
|
+
/** Allow-list of skill names. Unset = pi's default skill set. */
|
|
22
|
+
readonly skills?: readonly string[];
|
|
23
|
+
/** Deny-list of skill names, applied after `skills`. */
|
|
24
|
+
readonly disallowedSkills?: readonly string[];
|
|
25
|
+
/** Ask options applied to every Ask on this Agent unless overridden per call. */
|
|
26
|
+
readonly askDefaults?: AskOptions;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Not exported, so the config is unreachable outside this module. */
|
|
30
|
+
const definition = Symbol("yaag.agent");
|
|
31
|
+
|
|
32
|
+
/** Opaque, deep-frozen result of `defineAgent`. */
|
|
33
|
+
export interface AgentDefinition {
|
|
34
|
+
readonly [definition]: AgentConfig;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const arrayFields = ["tools", "disallowedTools", "skills", "disallowedSkills"] as const;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Declares an Agent Definition: reusable policy, independent of where it runs.
|
|
41
|
+
*
|
|
42
|
+
* Throws a TypeError when `name` is missing or blank, or when `cwd`/`worktree`
|
|
43
|
+
* appear — those are topology, chosen at spawn time, not baked into a definition.
|
|
44
|
+
* The config is defensively copied and deep-frozen, so later mutation of the
|
|
45
|
+
* caller's arrays cannot change the definition.
|
|
46
|
+
*/
|
|
47
|
+
export function defineAgent(config: AgentConfig): AgentDefinition {
|
|
48
|
+
if (typeof config.name !== "string" || config.name.trim() === "") {
|
|
49
|
+
throw new TypeError("defineAgent: name is required and must be non-empty");
|
|
50
|
+
}
|
|
51
|
+
if ("cwd" in config || "worktree" in config) {
|
|
52
|
+
throw new TypeError(
|
|
53
|
+
"defineAgent: cwd and worktree are topology concerns — set them at spawn time, not in the definition",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const copy: { -readonly [K in keyof AgentConfig]: AgentConfig[K] } = { ...config };
|
|
57
|
+
for (const field of arrayFields) {
|
|
58
|
+
const value = config[field];
|
|
59
|
+
if (value !== undefined) copy[field] = Object.freeze(value.slice());
|
|
60
|
+
}
|
|
61
|
+
if (config.askDefaults !== undefined) {
|
|
62
|
+
copy.askDefaults = Object.freeze({ ...config.askDefaults });
|
|
63
|
+
}
|
|
64
|
+
return Object.freeze({ [definition]: Object.freeze(copy) });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True only for values produced by `defineAgent`. */
|
|
68
|
+
export function isAgentDefinition(value: unknown): value is AgentDefinition {
|
|
69
|
+
return typeof value === "object" && value !== null && definition in value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Recovers the config behind an Agent Definition.
|
|
74
|
+
* Throws a TypeError when the value did not come from `defineAgent`.
|
|
75
|
+
*/
|
|
76
|
+
export function agentDefinitionConfig(value: AgentDefinition): AgentConfig;
|
|
77
|
+
export function agentDefinitionConfig(value: unknown): AgentConfig;
|
|
78
|
+
export function agentDefinitionConfig(value: unknown): AgentConfig {
|
|
79
|
+
if (!isAgentDefinition(value)) {
|
|
80
|
+
throw new TypeError("not an Agent Definition — build it with defineAgent({ name })");
|
|
81
|
+
}
|
|
82
|
+
return value[definition];
|
|
83
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Static, TSchema } from "typebox";
|
|
2
|
+
import type { RunContext } from "./run-context.ts";
|
|
3
|
+
|
|
4
|
+
/** What `defineRun` is given. */
|
|
5
|
+
export interface ProgramDefinition<Args = unknown, Result = unknown> {
|
|
6
|
+
readonly name?: string;
|
|
7
|
+
readonly description?: string;
|
|
8
|
+
/** Optional Typebox contract for arguments delivered to `run`. */
|
|
9
|
+
readonly args?: TSchema;
|
|
10
|
+
readonly run: (ctx: RunContext<Args>) => Result | Promise<Result>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type SchemaProgramDefinition<Schema extends TSchema, Result> = Omit<
|
|
14
|
+
ProgramDefinition<Static<Schema>, Result>,
|
|
15
|
+
"args"
|
|
16
|
+
> & {
|
|
17
|
+
readonly args: Schema;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type SchemalessProgramDefinition<Args, Result> = ProgramDefinition<Args, Result> & {
|
|
21
|
+
readonly args?: never;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Not exported, so the definition is unreachable outside this module. */
|
|
25
|
+
const definition = Symbol("yaag.program");
|
|
26
|
+
|
|
27
|
+
/** Opaque result of `defineRun`. Must be the module's default export. */
|
|
28
|
+
export interface OrchestrationProgram<Args = unknown, Result = unknown> {
|
|
29
|
+
readonly [definition]: ProgramDefinition<Args, Result>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Declares an Orchestration Program with arguments inferred from its Typebox schema.
|
|
34
|
+
*
|
|
35
|
+
* The returned value is opaque: exporting anything else as default is a compile
|
|
36
|
+
* error rather than a runtime "no program found".
|
|
37
|
+
*/
|
|
38
|
+
export function defineRun<Schema extends TSchema, Result>(
|
|
39
|
+
program: SchemaProgramDefinition<Schema, Result>,
|
|
40
|
+
): OrchestrationProgram<Static<Schema>, Result>;
|
|
41
|
+
/** Declares an Orchestration Program without an argument schema. */
|
|
42
|
+
export function defineRun<Args = unknown, Result = unknown>(
|
|
43
|
+
program: SchemalessProgramDefinition<Args, Result>,
|
|
44
|
+
): OrchestrationProgram<Args, Result>;
|
|
45
|
+
export function defineRun<Args, Result>(
|
|
46
|
+
program: ProgramDefinition<Args, Result>,
|
|
47
|
+
): OrchestrationProgram<Args, Result> {
|
|
48
|
+
return { [definition]: program };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True only for values produced by `defineRun`. */
|
|
52
|
+
export function isOrchestrationProgram(value: unknown): value is OrchestrationProgram {
|
|
53
|
+
return typeof value === "object" && value !== null && definition in value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Recovers the definition behind a program.
|
|
58
|
+
* Throws a TypeError when the value did not come from `defineRun`.
|
|
59
|
+
*/
|
|
60
|
+
export function programDefinition<Args, Result>(
|
|
61
|
+
value: OrchestrationProgram<Args, Result>,
|
|
62
|
+
): ProgramDefinition<Args, Result>;
|
|
63
|
+
export function programDefinition(value: unknown): ProgramDefinition;
|
|
64
|
+
export function programDefinition(value: unknown): ProgramDefinition {
|
|
65
|
+
if (!isOrchestrationProgram(value)) {
|
|
66
|
+
throw new TypeError("not an Orchestration Program — export defineRun({ run }) as default");
|
|
67
|
+
}
|
|
68
|
+
return value[definition];
|
|
69
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** Which soft Ask budget was exhausted. */
|
|
2
|
+
export type AskLimitKind = "turns" | "toolCalls" | "durationMs";
|
|
3
|
+
|
|
4
|
+
/** The recorded result when yaag aborts an Ask after a soft budget is exhausted. */
|
|
5
|
+
export interface AskLimitOutcome {
|
|
6
|
+
readonly kind: AskLimitKind;
|
|
7
|
+
readonly count: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The recorded result when yaag rejects an Ask because no frame arrived within `idleMs`. */
|
|
11
|
+
export interface AskStalledOutcome {
|
|
12
|
+
/** The configured silence threshold that tripped. */
|
|
13
|
+
readonly idleMs: number;
|
|
14
|
+
/** True when abort failed to settle the Agent and yaag killed the process. */
|
|
15
|
+
readonly destructive: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Recoverable result when all structured-output correction efforts are exhausted. */
|
|
19
|
+
export interface AskInvalidOutputOutcome {
|
|
20
|
+
/** Corrective steers sent before aborting this Ask. */
|
|
21
|
+
readonly steeringEfforts: number;
|
|
22
|
+
/** Final extraction or TypeBox diagnostics, localized to JSON paths. */
|
|
23
|
+
readonly errors: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Why an Ask, spawn, or Run failed. Programs may branch on this; most won't. */
|
|
27
|
+
export type YaagErrorCode =
|
|
28
|
+
| "AGENT_FAILED" // turn settled with stopReason error/aborted, or empty text
|
|
29
|
+
| "AGENT_DIED" // process exited with an Ask pending
|
|
30
|
+
| "AGENT_BUSY" // concurrent ask() on one Handle
|
|
31
|
+
| "ASK_TIMEOUT" // timeoutMs elapsed
|
|
32
|
+
| "ASK_LIMIT" // soft Ask budget exceeded after grace
|
|
33
|
+
| "ASK_STALLED" // no frame arrived within idleMs; escalated by abort, then kill
|
|
34
|
+
| "ASK_INVALID_OUTPUT" // settled text could not be extracted or satisfy outputSchema
|
|
35
|
+
| "ARGS_INVALID" // arguments failed schema validation before the Run started
|
|
36
|
+
| "OPTIONS_CONFLICT" // incompatible Run options were supplied
|
|
37
|
+
| "REPLAY_DIVERGED" // replayed program differed from its Cassette
|
|
38
|
+
| "RESUME_REFUSED" // resume metadata is absent or the recorded tree moved
|
|
39
|
+
| "RUN_CLOSED" // spawn was requested after the Run began settling
|
|
40
|
+
| "RUN_STOPPED" // the Run's abort signal fired before the program completed
|
|
41
|
+
| "WORKTREE_REFUSED" // base cwd is not a clean Git repository
|
|
42
|
+
| "SPAWN_FAILED"; // pi failed to start, e.g. unknown model
|
|
43
|
+
|
|
44
|
+
/** The single error class of the runtime (ADR-0003). */
|
|
45
|
+
export class YaagError extends Error {
|
|
46
|
+
readonly code: YaagErrorCode;
|
|
47
|
+
/** Agent name, when the error concerns one. */
|
|
48
|
+
readonly agent?: string;
|
|
49
|
+
/** Exhausted soft Ask budget, present only for `ASK_LIMIT`. */
|
|
50
|
+
readonly limit?: AskLimitKind;
|
|
51
|
+
/** Count observed when the soft Ask budget first tripped. */
|
|
52
|
+
readonly count?: number;
|
|
53
|
+
/** Silence threshold that tripped, present only for `ASK_STALLED`. */
|
|
54
|
+
readonly idleMs?: number;
|
|
55
|
+
/** True when an `ASK_STALLED` escalation had to kill the Agent. */
|
|
56
|
+
readonly destructive?: boolean;
|
|
57
|
+
/** Number of corrective output steers sent, present only for `ASK_INVALID_OUTPUT`. */
|
|
58
|
+
readonly steeringEfforts?: number;
|
|
59
|
+
/** Localized extraction or schema errors, present only for `ASK_INVALID_OUTPUT`. */
|
|
60
|
+
readonly errors?: readonly string[];
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
code: YaagErrorCode,
|
|
64
|
+
message: string,
|
|
65
|
+
agent?: string,
|
|
66
|
+
options?: AskLimitOutcome | AskStalledOutcome | AskInvalidOutputOutcome,
|
|
67
|
+
) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = "YaagError";
|
|
70
|
+
this.code = code;
|
|
71
|
+
if (agent !== undefined) this.agent = agent;
|
|
72
|
+
if (options !== undefined && "kind" in options) {
|
|
73
|
+
this.limit = options.kind;
|
|
74
|
+
this.count = options.count;
|
|
75
|
+
}
|
|
76
|
+
if (options !== undefined && "idleMs" in options) {
|
|
77
|
+
this.idleMs = options.idleMs;
|
|
78
|
+
this.destructive = options.destructive;
|
|
79
|
+
}
|
|
80
|
+
if (options !== undefined && "steeringEfforts" in options) {
|
|
81
|
+
this.steeringEfforts = options.steeringEfforts;
|
|
82
|
+
this.errors = options.errors;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Builds the runtime's uniform agent-scoped error message. */
|
|
88
|
+
export function agentError(agent: string, code: YaagErrorCode, message: string): YaagError {
|
|
89
|
+
return new YaagError(code, `agent "${agent}": ${message}`, agent);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The ASK_LIMIT rejection for one exhausted soft budget. */
|
|
93
|
+
export function askLimitError(agent: string, outcome: AskLimitOutcome): YaagError {
|
|
94
|
+
return new YaagError(
|
|
95
|
+
"ASK_LIMIT",
|
|
96
|
+
`agent "${agent}": ${outcome.kind} limit reached at ${outcome.count}`,
|
|
97
|
+
agent,
|
|
98
|
+
outcome,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The ASK_STALLED rejection for one tripped idle watch. */
|
|
103
|
+
export function askStalledError(agent: string, outcome: AskStalledOutcome): YaagError {
|
|
104
|
+
return new YaagError(
|
|
105
|
+
"ASK_STALLED",
|
|
106
|
+
`agent "${agent}": no frame for ${outcome.idleMs}ms${outcome.destructive ? " (killed)" : ""}`,
|
|
107
|
+
agent,
|
|
108
|
+
outcome,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Narrows an unknown rejection reason to a YaagError. */
|
|
113
|
+
export function isYaagError(value: unknown): value is YaagError {
|
|
114
|
+
return value instanceof YaagError;
|
|
115
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle Events (ADR-0006): a closed set of flat records, emitted above the
|
|
3
|
+
* transport seam. Live-only frame observers may add richer events than a
|
|
4
|
+
* Cassette replay produces; replay intentionally omits that observation data.
|
|
5
|
+
* Playback Asks still emit normal start/end events, but intentionally omit live
|
|
6
|
+
* activity, output, and usage observations. v0 renders them as stderr lines; the
|
|
7
|
+
* dedicated fd arrives with the extension.
|
|
8
|
+
*/
|
|
9
|
+
import type { TokenBreakdown, WorktreeResolution } from "./transport.ts";
|
|
10
|
+
|
|
11
|
+
/** A settled Run's terminal outcome (ADR-0022). `paused` arrives with the pause slice. */
|
|
12
|
+
export type RunOutcome = "completed" | "failed" | "stopped" | "paused";
|
|
13
|
+
|
|
14
|
+
/** The current, Ask-scoped observer projection derived from Agent frames. */
|
|
15
|
+
export type AgentActivity =
|
|
16
|
+
| { readonly type: "thinking" }
|
|
17
|
+
| { readonly type: "writing" }
|
|
18
|
+
| { readonly type: "tool"; readonly name: string; readonly argsGist: string }
|
|
19
|
+
| { readonly type: "compacting" }
|
|
20
|
+
| { readonly type: "retrying"; readonly attempt: number; readonly max: number };
|
|
21
|
+
|
|
22
|
+
/** The assistant stream channel carried by an additive `ask_output` event. */
|
|
23
|
+
export type AskOutputChannel = "text" | "thinking";
|
|
24
|
+
|
|
25
|
+
/** The lifecycle state of one Nested Node, as decoded from its parent's frames. */
|
|
26
|
+
export type NodeState = "running" | "exited" | "failed";
|
|
27
|
+
|
|
28
|
+
/** Accounting a nesting tool reports for one Nested Node; both fields are optional facts. */
|
|
29
|
+
export interface NodeUsage {
|
|
30
|
+
readonly tokens?: TokenBreakdown;
|
|
31
|
+
readonly cost?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type LifecycleEventBody =
|
|
35
|
+
| { readonly type: "run_start"; readonly program: string }
|
|
36
|
+
| {
|
|
37
|
+
readonly type: "agent_spawn";
|
|
38
|
+
readonly agent: string;
|
|
39
|
+
readonly model: string;
|
|
40
|
+
readonly cwd: string;
|
|
41
|
+
/** Present only when the Agent was spawned into a worktree. */
|
|
42
|
+
readonly branch?: string;
|
|
43
|
+
/**
|
|
44
|
+
* pi's persisted session file for this Agent, when the startup handshake
|
|
45
|
+
* reported one. A path is not a prompt payload, so it may ride the wire
|
|
46
|
+
* (ticket 09); a Peek reads this file to render the transcript. Absent for
|
|
47
|
+
* Cassette-playback Agents and events from older CLIs.
|
|
48
|
+
*/
|
|
49
|
+
readonly sessionFile?: string;
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
readonly type: "ask_start";
|
|
53
|
+
readonly agent: string;
|
|
54
|
+
readonly index: number;
|
|
55
|
+
/** First non-blank prompt line, whitespace-collapsed, ≤ PROMPT_GIST_MAX_CHARS. */
|
|
56
|
+
readonly promptGist: string;
|
|
57
|
+
/** Full prompt length; the prompt itself never rides the wire (ticket 09). */
|
|
58
|
+
readonly promptChars: number;
|
|
59
|
+
/** Present only when this individual Ask was served from Cassette playback. */
|
|
60
|
+
readonly replayed?: true;
|
|
61
|
+
}
|
|
62
|
+
| {
|
|
63
|
+
readonly type: "ask_activity";
|
|
64
|
+
readonly agent: string;
|
|
65
|
+
readonly index: number;
|
|
66
|
+
readonly activity: AgentActivity;
|
|
67
|
+
}
|
|
68
|
+
| {
|
|
69
|
+
readonly type: "ask_output";
|
|
70
|
+
readonly agent: string;
|
|
71
|
+
readonly index: number;
|
|
72
|
+
readonly channel: AskOutputChannel;
|
|
73
|
+
readonly text: string;
|
|
74
|
+
}
|
|
75
|
+
| {
|
|
76
|
+
/**
|
|
77
|
+
* A bounded snapshot of one Nested Node. It is the only nested-node event:
|
|
78
|
+
* there is no per-node spawn/ask/exit mirroring (spec §3). Unlike
|
|
79
|
+
* `ask_activity` and `ask_output`, it is re-derived on Cassette replay,
|
|
80
|
+
* because it decodes recorded Agent frames only (ADR-0006, ADR-0013).
|
|
81
|
+
*/
|
|
82
|
+
readonly type: "node_update";
|
|
83
|
+
readonly agent: string;
|
|
84
|
+
/** Node identity in the grammar `name(:askIndex(/childName…))`. */
|
|
85
|
+
readonly path: string;
|
|
86
|
+
readonly state: NodeState;
|
|
87
|
+
/** One line, ≤ NODE_GIST_MAX_CHARS; never a prompt (ticket 09). */
|
|
88
|
+
readonly activityGist?: string;
|
|
89
|
+
readonly usage?: NodeUsage;
|
|
90
|
+
}
|
|
91
|
+
| {
|
|
92
|
+
readonly type: "ask_end";
|
|
93
|
+
readonly agent: string;
|
|
94
|
+
readonly index: number;
|
|
95
|
+
readonly durationMs: number;
|
|
96
|
+
readonly ok: boolean;
|
|
97
|
+
/** Largest inter-frame silence during this Ask; absent during Cassette playback. */
|
|
98
|
+
readonly maxFrameGapMs?: number;
|
|
99
|
+
}
|
|
100
|
+
| {
|
|
101
|
+
readonly type: "agent_usage";
|
|
102
|
+
readonly agent: string;
|
|
103
|
+
/** Cumulative assistant-completion usage observed so far. */
|
|
104
|
+
readonly tokens: TokenBreakdown;
|
|
105
|
+
/** Cumulative assistant-completion cost observed so far. */
|
|
106
|
+
readonly cost: number;
|
|
107
|
+
}
|
|
108
|
+
| {
|
|
109
|
+
readonly type: "agent_exit";
|
|
110
|
+
readonly agent: string;
|
|
111
|
+
readonly tokens: TokenBreakdown | null;
|
|
112
|
+
readonly cost: number | null;
|
|
113
|
+
/** Cost is a floor, not a total: the Agent was killed mid-Ask (ADR-0012). */
|
|
114
|
+
readonly incomplete: boolean;
|
|
115
|
+
/** Surviving worktree identity, present only for worktree Agents. */
|
|
116
|
+
readonly worktree?: WorktreeResolution;
|
|
117
|
+
}
|
|
118
|
+
| {
|
|
119
|
+
readonly type: "run_end";
|
|
120
|
+
readonly ok: boolean;
|
|
121
|
+
/** Terminal outcome (ADR-0022); the invariant is `ok === (outcome === "completed")`. */
|
|
122
|
+
readonly outcome: RunOutcome;
|
|
123
|
+
readonly durationMs: number;
|
|
124
|
+
readonly cost: number;
|
|
125
|
+
/** Summed component-wise; null when any Agent's usage was unknown. */
|
|
126
|
+
readonly tokens: TokenBreakdown | null;
|
|
127
|
+
/** At least one Agent's cost was an undercount. */
|
|
128
|
+
readonly incomplete: boolean;
|
|
129
|
+
/** Largest inter-frame silence across every Ask; 0 when nothing was measured. */
|
|
130
|
+
readonly worstFrameGapMs: number;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A Lifecycle Event as observers see it: the body plus `at` — epoch-ms wall
|
|
135
|
+
* clock, stamped once in the Orchestrator's sink wrapper, never per emitter.
|
|
136
|
+
*/
|
|
137
|
+
export type LifecycleEvent = LifecycleEventBody & { readonly at: number };
|
|
138
|
+
|
|
139
|
+
/** Where emitters inside a Run report Lifecycle Event bodies, pre-stamp. */
|
|
140
|
+
export type EventSink = (event: LifecycleEventBody) => void;
|
|
141
|
+
|
|
142
|
+
/** Where a Run reports its stamped Lifecycle Events. */
|
|
143
|
+
export type StampedEventSink = (event: LifecycleEvent) => void;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import type { ExtensionSource, InstalledExtensionSource } from "./extension-source.ts";
|
|
5
|
+
|
|
6
|
+
/** Roots searched for an already-installed pi package, project before user. */
|
|
7
|
+
export interface ExtensionInstallRoots {
|
|
8
|
+
/** Agent working directory holding `.pi/npm` and `.pi/git`; defaults to `process.cwd()`. */
|
|
9
|
+
readonly projectRoot?: string;
|
|
10
|
+
/** pi user config directory; defaults to `~/.pi/agent`. */
|
|
11
|
+
readonly userConfigDir?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Expands an installed pi package into its declared extension entry files.
|
|
16
|
+
*
|
|
17
|
+
* Resolves the project install before the user install, then reads the package
|
|
18
|
+
* manifest's `pi.extensions` array in declaration order. Returns `undefined`
|
|
19
|
+
* when the package is not installed, so the caller can hand the raw specifier
|
|
20
|
+
* to pi and let it temp-install (docs/packages.md).
|
|
21
|
+
*/
|
|
22
|
+
export async function resolveInstalledExtension(
|
|
23
|
+
source: ExtensionSource,
|
|
24
|
+
roots: ExtensionInstallRoots = {},
|
|
25
|
+
): Promise<readonly string[] | undefined> {
|
|
26
|
+
if (source.kind === "path") return undefined;
|
|
27
|
+
for (const directory of candidateDirectories(source, roots)) {
|
|
28
|
+
if (await isDirectory(directory)) return await entryPoints(directory);
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function candidateDirectories(
|
|
34
|
+
source: InstalledExtensionSource,
|
|
35
|
+
roots: ExtensionInstallRoots,
|
|
36
|
+
): readonly string[] {
|
|
37
|
+
const projectRoot = resolve(roots.projectRoot ?? process.cwd());
|
|
38
|
+
const userConfigDir = roots.userConfigDir ?? join(homedir(), ".pi", "agent");
|
|
39
|
+
const suffix =
|
|
40
|
+
source.kind === "npm"
|
|
41
|
+
? join("npm", "node_modules", source.packageName)
|
|
42
|
+
: join("git", source.host, source.owner, source.repo);
|
|
43
|
+
return [join(projectRoot, ".pi", suffix), join(userConfigDir, suffix)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function entryPoints(directory: string): Promise<readonly string[]> {
|
|
47
|
+
const manifest = await readManifest(join(directory, "package.json"));
|
|
48
|
+
const declared = manifest?.pi?.extensions;
|
|
49
|
+
if (declared !== undefined && declared.length > 0) {
|
|
50
|
+
return declared.map((entry) => resolve(directory, entry));
|
|
51
|
+
}
|
|
52
|
+
if (typeof manifest?.main === "string") return [resolve(directory, manifest.main)];
|
|
53
|
+
return [join(directory, "index.ts")];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface PackageManifest {
|
|
57
|
+
readonly main?: string;
|
|
58
|
+
readonly pi?: { readonly extensions?: readonly string[] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readManifest(path: string): Promise<PackageManifest | undefined> {
|
|
62
|
+
let text: string;
|
|
63
|
+
try {
|
|
64
|
+
text = await readFile(path, "utf8");
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const value: unknown = JSON.parse(text);
|
|
69
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
70
|
+
const main = "main" in value && typeof value.main === "string" ? value.main : undefined;
|
|
71
|
+
return { ...(main === undefined ? {} : { main }), pi: { extensions: readExtensions(value) } };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readExtensions(value: object): readonly string[] | undefined {
|
|
75
|
+
if (!("pi" in value) || typeof value.pi !== "object" || value.pi === null) return undefined;
|
|
76
|
+
const pi: object = value.pi;
|
|
77
|
+
if (!("extensions" in pi) || !Array.isArray(pi.extensions)) return undefined;
|
|
78
|
+
const entries = pi.extensions.filter((entry): entry is string => typeof entry === "string");
|
|
79
|
+
return entries.length === 0 ? undefined : entries;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
83
|
+
try {
|
|
84
|
+
return (await stat(path)).isDirectory();
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { type ExtensionInstallRoots, resolveInstalledExtension } from "./extension-package.ts";
|
|
4
|
+
import { parseExtensionSource } from "./extension-source.ts";
|
|
5
|
+
|
|
6
|
+
/** Inputs that decide how a declaration becomes a launch-ready `-e` argument. */
|
|
7
|
+
export interface ExtensionResolutionOptions extends ExtensionInstallRoots {
|
|
8
|
+
/** Orchestration Program source path, required by relative declarations. */
|
|
9
|
+
readonly programFile?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolves declared extensions into launch-ready `pi -e` arguments before transport opening.
|
|
14
|
+
*
|
|
15
|
+
* Filesystem declarations are resolved against the Orchestration Program directory and
|
|
16
|
+
* checked for existence. `npm:`/`git:` specifiers expand to the entry points of the
|
|
17
|
+
* already-installed package (project install shadows the user install); an uninstalled
|
|
18
|
+
* specifier passes through verbatim so pi temp-installs it. Results retain declaration
|
|
19
|
+
* order. Rejects missing paths and a relative declaration without `programFile`.
|
|
20
|
+
*/
|
|
21
|
+
export async function resolveExtensionPaths(
|
|
22
|
+
declarations: readonly string[],
|
|
23
|
+
options: ExtensionResolutionOptions = {},
|
|
24
|
+
): Promise<readonly string[]> {
|
|
25
|
+
const paths: string[] = [];
|
|
26
|
+
for (const declaration of declarations) {
|
|
27
|
+
paths.push(...(await resolveOne(declaration, options)));
|
|
28
|
+
}
|
|
29
|
+
return paths;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function resolveOne(
|
|
33
|
+
declaration: string,
|
|
34
|
+
options: ExtensionResolutionOptions,
|
|
35
|
+
): Promise<readonly string[]> {
|
|
36
|
+
const source = parseExtensionSource(declaration);
|
|
37
|
+
if (source.kind === "path") {
|
|
38
|
+
const path = resolveDeclaration(declaration, options.programFile);
|
|
39
|
+
await requireExists(declaration, path);
|
|
40
|
+
return [path];
|
|
41
|
+
}
|
|
42
|
+
const installed = await resolveInstalledExtension(source, options);
|
|
43
|
+
if (installed === undefined) return [declaration];
|
|
44
|
+
for (const path of installed) await requireExists(declaration, path);
|
|
45
|
+
return installed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveDeclaration(declaration: string, programFile: string | undefined): string {
|
|
49
|
+
if (isAbsolute(declaration)) return declaration;
|
|
50
|
+
if (programFile === undefined) {
|
|
51
|
+
throw new Error(`relative extension "${declaration}" requires RunOptions.programFile`);
|
|
52
|
+
}
|
|
53
|
+
return resolve(dirname(programFile), declaration);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function requireExists(declaration: string, path: string): Promise<void> {
|
|
57
|
+
try {
|
|
58
|
+
await lstat(path);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
// Only absence is translated; permission errors and the like surface as-is.
|
|
61
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
62
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
|
|
63
|
+
if (declaration === path) throw new Error(`extension "${path}" does not exist`);
|
|
64
|
+
throw new Error(`extension "${declaration}" does not exist at "${path}"`);
|
|
65
|
+
}
|
|
66
|
+
}
|