@mku1tra/que-pasa 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/define/agent.d.ts +23 -0
- package/dist/define/agent.js +29 -0
- package/dist/define/index.d.ts +3 -0
- package/dist/define/index.js +2 -0
- package/dist/define/runtime.d.ts +17 -0
- package/dist/define/runtime.js +1 -0
- package/dist/define/tool.d.ts +21 -0
- package/dist/define/tool.js +14 -0
- package/dist/loop/extension.d.ts +22 -0
- package/dist/loop/extension.js +91 -0
- package/dist/loop/index.d.ts +2 -0
- package/dist/loop/index.js +2 -0
- package/dist/loop/runtime.d.ts +61 -0
- package/dist/loop/runtime.js +132 -0
- package/dist/loop/state.d.ts +28 -0
- package/dist/loop/state.js +20 -0
- package/dist/loop/trace.d.ts +37 -0
- package/dist/loop/trace.js +373 -0
- package/dist/loop/transcript.d.ts +18 -0
- package/dist/loop/transcript.js +43 -0
- package/package.json +60 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Kelly
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# que-pasa
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
que-v2's harness, on pi-durable: a workspace defines its agents with `@mku1tra/que-pasa/define`, and `@mku1tra/que-pasa/loop` runs them, durably and traced. It knows no workspace. What is built: `que-pasa.md`.
|
|
4
|
+
|
|
5
|
+
## Run and check
|
|
6
|
+
|
|
7
|
+
Node runs in the dev container (`.devcontainer/devcontainer.json`), never on the host. You need `just`, `podman` and `npx`.
|
|
8
|
+
|
|
9
|
+
- `just up`: make or start this checkout's dev container
|
|
10
|
+
- `just check`: the check set, `scripts/check.sh`: notes, then types, lint, format, tests and the build. The pre-push hook and the pipeline run the same file
|
|
11
|
+
- `just run <command>`: run a command in the dev container, such as `just run npm install zod`
|
|
12
|
+
- `just down`: remove this checkout's container and its own volumes
|
|
13
|
+
|
|
14
|
+
## Run agents
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
|
|
18
|
+
import { openRuntime } from "@mku1tra/que-pasa/loop";
|
|
19
|
+
|
|
20
|
+
const runtime = await openRuntime({ storage: await openNodeSqliteStorage("runs.sqlite"), models, agents: [nail] });
|
|
21
|
+
const result = await runtime.run(nail, input, { runId: attempt.id, caller: { id: "assembly" }, context });
|
|
22
|
+
await runtime.close();
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
One runtime per process owns its SQLite file. After a crash, open it again with the same agents: unfinished runs go on, and `run` with the same `runId` gives back the result.
|
|
26
|
+
|
|
27
|
+
Each run is traced as OpenTelemetry GenAI spans through `@opentelemetry/api`. To keep them, register an SDK (a tracer provider, an exporter, and an async context manager). To add what only the host knows, call `runtime.run()` inside a span of its own. `captureContent: true` on `openRuntime` adds message content.
|
|
28
|
+
|
|
29
|
+
## Use it from another project
|
|
30
|
+
|
|
31
|
+
Install it from npm: `npm install @mku1tra/que-pasa zod`. zod is a peer dependency: the project brings it.
|
|
32
|
+
|
|
33
|
+
## Release
|
|
34
|
+
|
|
35
|
+
Set the version in `package.json`, commit, land, and push its tag, `v<version>`. `.github/workflows/publish.yml` runs the check set and publishes on npm by trusted publishing (no token, with provenance). A ruleset lets only an admin of the repo make a version tag. A version npm has is not published again. A published version cannot change: npm unpublishes only within 72 hours.
|
|
36
|
+
|
|
37
|
+
## Ship
|
|
38
|
+
|
|
39
|
+
A change lands on main through `git land`.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { Json } from "./runtime.ts";
|
|
3
|
+
import type { Tool } from "./tool.ts";
|
|
4
|
+
/** One agent's picks from a workspace: the core every agent has. C is the context its runs are given, which its tools read. */
|
|
5
|
+
export type Agent<O extends z.ZodObject | undefined = z.ZodObject | undefined, C extends Json | undefined = undefined> = {
|
|
6
|
+
/** kebab-case, as its file is named. */
|
|
7
|
+
name: string;
|
|
8
|
+
/** "provider:model-id". */
|
|
9
|
+
model: string;
|
|
10
|
+
/** What the agent is and how it works: the start of its system prompt. */
|
|
11
|
+
instructions: string;
|
|
12
|
+
/** Passed explicitly: the agent can call these and no others. */
|
|
13
|
+
tools: readonly Tool<z.ZodObject, C>[];
|
|
14
|
+
/** The shape of its result. Left out, the agent answers in chat text. */
|
|
15
|
+
output?: O;
|
|
16
|
+
};
|
|
17
|
+
/** A model id, "provider:model-id", as its provider and its id: throws on any other shape. */
|
|
18
|
+
export declare function parseModel(model: string): {
|
|
19
|
+
provider: string;
|
|
20
|
+
modelId: string;
|
|
21
|
+
};
|
|
22
|
+
/** An agent, checked: its name, its model id, its instructions, and tool names that do not repeat. */
|
|
23
|
+
export declare function defineAgent<O extends z.ZodObject | undefined = undefined, C extends Json | undefined = undefined>(agent: Agent<O, C>): Agent<O, C>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const agentName = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
2
|
+
const modelId = /^[^:\s]+:\S+$/;
|
|
3
|
+
/** A model id, "provider:model-id", as its provider and its id: throws on any other shape. */
|
|
4
|
+
export function parseModel(model) {
|
|
5
|
+
if (!modelId.test(model))
|
|
6
|
+
throw new Error(`the model must be "provider:model-id", not "${model}"`);
|
|
7
|
+
const at = model.indexOf(":");
|
|
8
|
+
return { provider: model.slice(0, at), modelId: model.slice(at + 1) };
|
|
9
|
+
}
|
|
10
|
+
/** An agent, checked: its name, its model id, its instructions, and tool names that do not repeat. */
|
|
11
|
+
export function defineAgent(agent) {
|
|
12
|
+
if (!agentName.test(agent.name)) {
|
|
13
|
+
throw new Error(`agent "${agent.name}": the name must be kebab-case`);
|
|
14
|
+
}
|
|
15
|
+
if (!modelId.test(agent.model)) {
|
|
16
|
+
throw new Error(`agent "${agent.name}": the model must be "provider:model-id", not "${agent.model}"`);
|
|
17
|
+
}
|
|
18
|
+
if (agent.instructions.trim() === "") {
|
|
19
|
+
throw new Error(`agent "${agent.name}": the instructions are empty`);
|
|
20
|
+
}
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
for (const tool of agent.tools) {
|
|
23
|
+
if (seen.has(tool.name)) {
|
|
24
|
+
throw new Error(`agent "${agent.name}": two tools are named "${tool.name}"`);
|
|
25
|
+
}
|
|
26
|
+
seen.add(tool.name);
|
|
27
|
+
}
|
|
28
|
+
return Object.freeze({ ...agent, tools: Object.freeze([...agent.tools]) });
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The person or program a run acts for. Every run carries one: no tool or agent passes it on. */
|
|
2
|
+
export type Caller = {
|
|
3
|
+
id: string;
|
|
4
|
+
};
|
|
5
|
+
/** A JSON value: what a run keeps in storage, so it survives a restart. */
|
|
6
|
+
export type Json = string | number | boolean | null | Json[] | {
|
|
7
|
+
[key: string]: Json;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* What a tool gets from its run, besides its input: the caller, and the context the run was given (LangChain's
|
|
11
|
+
* runtime context). The context is the host's, per run, such as the sandbox an attempt's tools exec into. It is JSON,
|
|
12
|
+
* because the run keeps it, and a run that resumes after a restart gives its tools the same one.
|
|
13
|
+
*/
|
|
14
|
+
export type Runtime<C extends Json | undefined = undefined> = {
|
|
15
|
+
caller: Caller;
|
|
16
|
+
context: C;
|
|
17
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { Json, Runtime } from "./runtime.ts";
|
|
3
|
+
/** A function an agent can call: written once in a workspace, so any agent in it can be given it. */
|
|
4
|
+
export type Tool<S extends z.ZodObject = z.ZodObject, C extends Json | undefined = undefined> = {
|
|
5
|
+
/** What the model calls it by: snake_case, at most 64 characters. */
|
|
6
|
+
name: string;
|
|
7
|
+
/** What it does and when to call it. The model reads it to choose. */
|
|
8
|
+
description: string;
|
|
9
|
+
/** The input the model must give. */
|
|
10
|
+
schema: S;
|
|
11
|
+
/** The name of the connection it reads at call time. The tool never holds the credential. */
|
|
12
|
+
connection?: string;
|
|
13
|
+
/**
|
|
14
|
+
* "safe": a call a crash cut off runs again when the run resumes. Left out, it never runs twice: the model gets an
|
|
15
|
+
* `interrupted` result and decides what to do. pi's own term.
|
|
16
|
+
*/
|
|
17
|
+
replay?: "safe";
|
|
18
|
+
run(input: z.infer<S>, runtime: Runtime<C>): unknown;
|
|
19
|
+
};
|
|
20
|
+
/** A tool, checked: its name is one the model providers take, and it says what it does. */
|
|
21
|
+
export declare function defineTool<S extends z.ZodObject, C extends Json | undefined = undefined>(tool: Tool<S, C>): Tool<S, C>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const toolName = /^[a-z][a-z0-9_]{0,63}$/;
|
|
2
|
+
/** A tool, checked: its name is one the model providers take, and it says what it does. */
|
|
3
|
+
export function defineTool(tool) {
|
|
4
|
+
if (!toolName.test(tool.name)) {
|
|
5
|
+
throw new Error(`tool "${tool.name}": the name must be snake_case, at most 64 characters`);
|
|
6
|
+
}
|
|
7
|
+
if (tool.description.trim() === "") {
|
|
8
|
+
throw new Error(`tool "${tool.name}": the description is empty`);
|
|
9
|
+
}
|
|
10
|
+
if (tool.connection !== undefined && tool.connection.trim() === "") {
|
|
11
|
+
throw new Error(`tool "${tool.name}": the connection name is empty`);
|
|
12
|
+
}
|
|
13
|
+
return Object.freeze({ ...tool });
|
|
14
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Extension } from "@earendil-works/pi-durable";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { Agent, Json } from "../define/index.ts";
|
|
4
|
+
/** Any agent, as the runtime holds it. */
|
|
5
|
+
export type AnyAgent = Agent<z.ZodObject | undefined, Json | undefined>;
|
|
6
|
+
/** The tool an agent with an output calls to give its result. No agent tool may take its name. */
|
|
7
|
+
export declare const submitTool = "submit";
|
|
8
|
+
/** What a run says to an agent with an output that answered without calling submit. */
|
|
9
|
+
export declare const reminder = "You have not given your result. Call submit with it.";
|
|
10
|
+
/** The name of an agent's extension: stable, so a run that resumes after a restart finds the agent's code. */
|
|
11
|
+
export declare const extensionName: (agent: AnyAgent) => string;
|
|
12
|
+
/**
|
|
13
|
+
* An agent as one pi extension: its tools, and, for an agent with an output, the submit tool and the reminder. It holds
|
|
14
|
+
* no run's state: each call reads its run's document and transcript, so it serves every run of the agent, also one that
|
|
15
|
+
* resumes in a new process.
|
|
16
|
+
*/
|
|
17
|
+
export declare function agentExtension(agent: AnyAgent): Extension;
|
|
18
|
+
/**
|
|
19
|
+
* The JSON Schema of what the model must give: the input shape, so a field with a default is not required. Without the
|
|
20
|
+
* `$schema` key a tool's parameters do not take.
|
|
21
|
+
*/
|
|
22
|
+
export declare function jsonSchema(schema: z.ZodObject): object;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { defineExtension, defineTool as definePiTool, GenerationTask, hook, } from "@earendil-works/pi-durable";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { RunDoc } from "./state.js";
|
|
4
|
+
import { messagesOf, resultOf, userText } from "./transcript.js";
|
|
5
|
+
/** The tool an agent with an output calls to give its result. No agent tool may take its name. */
|
|
6
|
+
export const submitTool = "submit";
|
|
7
|
+
/** What a run says to an agent with an output that answered without calling submit. */
|
|
8
|
+
export const reminder = `You have not given your result. Call ${submitTool} with it.`;
|
|
9
|
+
/** How many reminders a run sends before it ends with no result. */
|
|
10
|
+
const reminders = 2;
|
|
11
|
+
/** The name of an agent's extension: stable, so a run that resumes after a restart finds the agent's code. */
|
|
12
|
+
export const extensionName = (agent) => `agent-${agent.name}`;
|
|
13
|
+
/**
|
|
14
|
+
* An agent as one pi extension: its tools, and, for an agent with an output, the submit tool and the reminder. It holds
|
|
15
|
+
* no run's state: each call reads its run's document and transcript, so it serves every run of the agent, also one that
|
|
16
|
+
* resumes in a new process.
|
|
17
|
+
*/
|
|
18
|
+
export function agentExtension(agent) {
|
|
19
|
+
const { output } = agent;
|
|
20
|
+
if (output && agent.tools.some((tool) => tool.name === submitTool)) {
|
|
21
|
+
throw new Error(`agent "${agent.name}": a tool is named "${submitTool}", which its output needs`);
|
|
22
|
+
}
|
|
23
|
+
const tools = agent.tools.map(toPiTool);
|
|
24
|
+
if (output)
|
|
25
|
+
tools.push(submit(output));
|
|
26
|
+
return defineExtension({
|
|
27
|
+
name: extensionName(agent),
|
|
28
|
+
tools,
|
|
29
|
+
hooks: output ? [remind] : [],
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** A que-pasa tool as a pi tool: pi checks the arguments against the JSON Schema, zod parses them, and the run's runtime goes in. */
|
|
33
|
+
function toPiTool(tool) {
|
|
34
|
+
return definePiTool({
|
|
35
|
+
name: tool.name,
|
|
36
|
+
description: tool.description,
|
|
37
|
+
parameters: jsonSchema(tool.schema),
|
|
38
|
+
...(tool.replay ? { replay: tool.replay } : {}),
|
|
39
|
+
execute: async (args, api, context) => {
|
|
40
|
+
const result = await tool.run(tool.schema.parse(args), await runtimeOf(api, context));
|
|
41
|
+
return {
|
|
42
|
+
output: [
|
|
43
|
+
{
|
|
44
|
+
type: "text",
|
|
45
|
+
text: typeof result === "string" ? result : JSON.stringify(result),
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** The submit tool: a result that fails the schema throws, and the model gets the error to try again. A good one is kept in the transcript, as the result's details, and ends the run. */
|
|
53
|
+
function submit(output) {
|
|
54
|
+
return definePiTool({
|
|
55
|
+
name: submitTool,
|
|
56
|
+
description: "Give your final result. Call it once, when you are done.",
|
|
57
|
+
parameters: jsonSchema(output),
|
|
58
|
+
replay: "safe",
|
|
59
|
+
execute: async (args) => ({
|
|
60
|
+
output: [{ type: "text", text: "Result received." }],
|
|
61
|
+
details: output.parse(args),
|
|
62
|
+
control: { terminate: true },
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** Reminds an agent with an output that answered without submitting, at most `reminders` times, counted in its transcript. */
|
|
67
|
+
const remind = hook(GenerationTask, {
|
|
68
|
+
onYield: async (_answer, api, context) => {
|
|
69
|
+
const messages = await messagesOf(api, context);
|
|
70
|
+
if (resultOf(messages, submitTool))
|
|
71
|
+
return undefined;
|
|
72
|
+
const sent = messages.filter((message) => userText(message) === reminder).length;
|
|
73
|
+
return sent < reminders ? { continue: reminder } : undefined;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
/** The runtime of the call's run, from its run document. */
|
|
77
|
+
async function runtimeOf(api, context) {
|
|
78
|
+
const run = await api.snapshot(RunDoc, api.conversationId, context);
|
|
79
|
+
if (!run)
|
|
80
|
+
throw new Error("the run has no run document");
|
|
81
|
+
return { caller: run.caller, context: run.context };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The JSON Schema of what the model must give: the input shape, so a field with a default is not required. Without the
|
|
85
|
+
* `$schema` key a tool's parameters do not take.
|
|
86
|
+
*/
|
|
87
|
+
export function jsonSchema(schema) {
|
|
88
|
+
const json = z.toJSONSchema(schema, { io: "input" });
|
|
89
|
+
delete json["$schema"];
|
|
90
|
+
return json;
|
|
91
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Message, Models } from "@earendil-works/pi-ai";
|
|
2
|
+
import { type Storage, type UsageState } from "@earendil-works/pi-durable";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
import { type Agent, type Caller, type Json } from "../define/index.ts";
|
|
5
|
+
import { type AnyAgent } from "./extension.ts";
|
|
6
|
+
/** The tokens and cost a run spent: per `provider/model-id` for model calls, and per tool for tools that report it. */
|
|
7
|
+
export type RunUsage = UsageState;
|
|
8
|
+
/** What a run gives back: the agent's output, checked against its schema, or its answer as text. */
|
|
9
|
+
export type RunResult<O> = O extends z.ZodObject ? z.infer<O> : string;
|
|
10
|
+
/** What a runtime needs: where it keeps its runs, the models it may call, and every agent it can run. */
|
|
11
|
+
export type RuntimeOptions = {
|
|
12
|
+
/** pi's storage: `openNodeSqliteStorage(file)` to keep runs across restarts, `MemoryStorage` to keep none. */
|
|
13
|
+
storage: Storage;
|
|
14
|
+
/** The host's models, with its providers set. */
|
|
15
|
+
models: Models;
|
|
16
|
+
/** Every agent the host can run. A run that resumes after a restart finds its agent here, by name. */
|
|
17
|
+
agents: readonly AnyAgent[];
|
|
18
|
+
/**
|
|
19
|
+
* Record message content in the trace: instructions, tool definitions, messages, tool arguments and results. Off by
|
|
20
|
+
* default, as the GenAI conventions say: content can hold sensitive data.
|
|
21
|
+
*/
|
|
22
|
+
captureContent?: boolean;
|
|
23
|
+
};
|
|
24
|
+
/** What a run needs besides the agent and its input. */
|
|
25
|
+
export type RunOptions<C extends Json | undefined> = {
|
|
26
|
+
/**
|
|
27
|
+
* The host's id for the run. A run started again with the same id is the same run: it goes on from where it is, or
|
|
28
|
+
* gives back its result, and its input, caller, model and context are the ones it was started with.
|
|
29
|
+
*/
|
|
30
|
+
runId: string;
|
|
31
|
+
/** Who the run acts for. */
|
|
32
|
+
caller: Caller;
|
|
33
|
+
/** The model to run on, "provider:model-id", in place of the agent's: such as a fallback for a retry. */
|
|
34
|
+
model?: string;
|
|
35
|
+
/** Stops the run: run() rejects with the signal's reason, and no tool or model call starts after it. */
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
/** Called once when the run ends, answered or not, with what it spent. */
|
|
38
|
+
onUsage?: (usage: RunUsage) => void;
|
|
39
|
+
/**
|
|
40
|
+
* Called once when the run ends, answered or not, with its whole conversation in order, as pi-ai's messages: the
|
|
41
|
+
* input, each assistant message with its tool calls, each tool result, and the reminders the run sent. The system
|
|
42
|
+
* prompt is the agent's instructions, and is not in it.
|
|
43
|
+
*/
|
|
44
|
+
onMessages?: (messages: Message[]) => void;
|
|
45
|
+
} & (undefined extends C ? {
|
|
46
|
+
context?: C;
|
|
47
|
+
} : {
|
|
48
|
+
context: C;
|
|
49
|
+
});
|
|
50
|
+
/** A host's agents on one pi harness: open once per process, run any number of runs, close at the end. */
|
|
51
|
+
export type Runtime = {
|
|
52
|
+
/** Run one agent on one input to its result, or go on with the run of this `runId`. */
|
|
53
|
+
run<O extends z.ZodObject | undefined, C extends Json | undefined>(agent: Agent<O, C>, input: string, options: RunOptions<C>): Promise<RunResult<O>>;
|
|
54
|
+
/** Close the harness. A run still going is kept, and goes on when a runtime opens on the same storage. */
|
|
55
|
+
close(): Promise<void>;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Open a runtime: one pi harness on `storage`, with each agent installed as its own extension. Runs a crash left
|
|
59
|
+
* unfinished go on at once.
|
|
60
|
+
*/
|
|
61
|
+
export declare function openRuntime({ storage, models, agents, captureContent, }: RuntimeOptions): Promise<Runtime>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { BACKGROUND_CONTEXT, withAbortSignal, } from "@earendil-works/chord/context";
|
|
2
|
+
import { configure, createRegistry, Harness, UsageDoc, } from "@earendil-works/pi-durable";
|
|
3
|
+
import { parseModel, } from "../define/index.js";
|
|
4
|
+
import { agentExtension, submitTool } from "./extension.js";
|
|
5
|
+
import { RunDoc, RunIndex } from "./state.js";
|
|
6
|
+
import { activeParent, createTracing } from "./trace.js";
|
|
7
|
+
import { answerOf, messagesOf, resultOf } from "./transcript.js";
|
|
8
|
+
const context = BACKGROUND_CONTEXT;
|
|
9
|
+
/**
|
|
10
|
+
* Open a runtime: one pi harness on `storage`, with each agent installed as its own extension. Runs a crash left
|
|
11
|
+
* unfinished go on at once.
|
|
12
|
+
*/
|
|
13
|
+
export async function openRuntime({ storage, models, agents, captureContent = false, }) {
|
|
14
|
+
const installed = new Map();
|
|
15
|
+
for (const agent of agents) {
|
|
16
|
+
if (installed.has(agent.name))
|
|
17
|
+
throw new Error(`two agents are named "${agent.name}"`);
|
|
18
|
+
installed.set(agent.name, { agent, extension: agentExtension(agent) });
|
|
19
|
+
}
|
|
20
|
+
const tracing = createTracing({
|
|
21
|
+
agents: new Map([...installed].map(([name, { agent }]) => [name, agent])),
|
|
22
|
+
captureContent,
|
|
23
|
+
});
|
|
24
|
+
const registry = createRegistry();
|
|
25
|
+
for (const { extension } of installed.values())
|
|
26
|
+
registry.install(extension);
|
|
27
|
+
registry.install(tracing.extension);
|
|
28
|
+
const harness = await Harness.open(storage, { models, registry }, context);
|
|
29
|
+
tracing.bind(harness);
|
|
30
|
+
harness.resume();
|
|
31
|
+
/**
|
|
32
|
+
* The run's conversation: made with its agent, its model and its run document in one commit, or the one this run id
|
|
33
|
+
* already has, which keeps what it was started with.
|
|
34
|
+
*/
|
|
35
|
+
async function conversationOf(agent, extension, start) {
|
|
36
|
+
const { runId } = start;
|
|
37
|
+
const id = await harness.commit(async (tx) => {
|
|
38
|
+
const index = await tx.doc(RunIndex, runId, null);
|
|
39
|
+
if (index.conversationId !== null) {
|
|
40
|
+
const kept = await tx.doc(RunDoc, index.conversationId);
|
|
41
|
+
if (kept.agent !== agent.name) {
|
|
42
|
+
throw new Error(`run "${runId}" is of agent "${kept.agent}", not "${agent.name}"`);
|
|
43
|
+
}
|
|
44
|
+
return index.conversationId;
|
|
45
|
+
}
|
|
46
|
+
const { id } = await tx.createConversation({
|
|
47
|
+
ownership: { kind: "ownerless" },
|
|
48
|
+
});
|
|
49
|
+
await configure(tx, id, {
|
|
50
|
+
model: parseModel(start.model),
|
|
51
|
+
instructions: agent.instructions,
|
|
52
|
+
extensions: [extension, tracing.extension],
|
|
53
|
+
});
|
|
54
|
+
Object.assign(await tx.doc(RunDoc, id), start);
|
|
55
|
+
index.conversationId = id;
|
|
56
|
+
tracing.start(id);
|
|
57
|
+
return id;
|
|
58
|
+
}, context);
|
|
59
|
+
const conversation = await harness.conversation(id, context);
|
|
60
|
+
if (!conversation)
|
|
61
|
+
throw new Error(`run "${runId}": its conversation is missing`);
|
|
62
|
+
return conversation;
|
|
63
|
+
}
|
|
64
|
+
async function run(agent, input, options) {
|
|
65
|
+
const entry = installed.get(agent.name);
|
|
66
|
+
if (entry?.agent !== agent) {
|
|
67
|
+
throw new Error(`agent "${agent.name}" was not given when the runtime opened`);
|
|
68
|
+
}
|
|
69
|
+
options.signal?.throwIfAborted();
|
|
70
|
+
const { runId, caller, signal, onUsage, onMessages } = options;
|
|
71
|
+
const model = options.model ?? agent.model;
|
|
72
|
+
parseModel(model);
|
|
73
|
+
const parent = activeParent();
|
|
74
|
+
const conversation = await conversationOf(agent, entry.extension, {
|
|
75
|
+
runId,
|
|
76
|
+
agent: agent.name,
|
|
77
|
+
model,
|
|
78
|
+
caller,
|
|
79
|
+
...(options.context === undefined ? {} : { context: options.context }),
|
|
80
|
+
...(parent === undefined ? {} : { trace: parent }),
|
|
81
|
+
});
|
|
82
|
+
let messages;
|
|
83
|
+
const transcript = async () => (messages ??= await messagesOf(conversation, context));
|
|
84
|
+
try {
|
|
85
|
+
// The same request id finds the run's input when it is in already: a run started again never runs twice.
|
|
86
|
+
const submission = await conversation.submit({ type: "input", content: input, requestId: runId }, context);
|
|
87
|
+
const settled = await waitFor(submission, conversation, signal);
|
|
88
|
+
if (settled.status !== "done") {
|
|
89
|
+
throw new Error(`agent "${agent.name}": the run ended with no answer (${settled.reason})`);
|
|
90
|
+
}
|
|
91
|
+
if (!agent.output)
|
|
92
|
+
return answerOf(await transcript());
|
|
93
|
+
const result = resultOf(await transcript(), submitTool);
|
|
94
|
+
if (!result)
|
|
95
|
+
throw new Error(`agent "${agent.name}": the run ended with no ${submitTool}`);
|
|
96
|
+
return result.value;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
// The run's trace ends before run() does, so a host's span around it holds the whole run.
|
|
100
|
+
await tracing.ended(conversation.id);
|
|
101
|
+
onUsage?.((await harness.snapshot(UsageDoc, conversation.id, context)) ?? {
|
|
102
|
+
models: {},
|
|
103
|
+
tools: {},
|
|
104
|
+
});
|
|
105
|
+
onMessages?.(await transcript());
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
run,
|
|
110
|
+
async close() {
|
|
111
|
+
await tracing.closeAll();
|
|
112
|
+
await harness.close(context);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Wait for the run to settle. On abort, pi cancels only the wait and never the work, so the conversation is aborted
|
|
118
|
+
* too: its tasks stop, and the run rejects with the signal's reason.
|
|
119
|
+
*/
|
|
120
|
+
async function waitFor(submission, conversation, signal) {
|
|
121
|
+
if (!signal)
|
|
122
|
+
return submission.wait(context);
|
|
123
|
+
try {
|
|
124
|
+
return await submission.wait(withAbortSignal(signal, context));
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (!signal.aborted)
|
|
128
|
+
throw error;
|
|
129
|
+
await conversation.abort(context);
|
|
130
|
+
throw signal.reason;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ConversationId } from "@earendil-works/pi-durable";
|
|
2
|
+
import type { Caller, Json } from "../define/index.ts";
|
|
3
|
+
/** A span a run's trace hangs from, as plain JSON: the ids of OpenTelemetry's span context. */
|
|
4
|
+
export type TraceParent = {
|
|
5
|
+
traceId: string;
|
|
6
|
+
spanId: string;
|
|
7
|
+
traceFlags: number;
|
|
8
|
+
};
|
|
9
|
+
/** What a run keeps besides its transcript: who it is for, and what its tools are given. One per run's conversation. */
|
|
10
|
+
export type RunState = {
|
|
11
|
+
/** The host's id for the run. */
|
|
12
|
+
runId: string;
|
|
13
|
+
/** The agent it runs. */
|
|
14
|
+
agent: string;
|
|
15
|
+
/** The model it runs on, "provider:model-id": the agent's, or the one the run was started with. */
|
|
16
|
+
model: string;
|
|
17
|
+
caller: Caller;
|
|
18
|
+
/** Absent when the run was given none. */
|
|
19
|
+
context?: Json;
|
|
20
|
+
/** The parent of each of its trace's segments: the host's span it was started in, or its first segment. */
|
|
21
|
+
trace?: TraceParent;
|
|
22
|
+
};
|
|
23
|
+
/** The run's own document, written in the commit that makes its conversation. */
|
|
24
|
+
export declare const RunDoc: import("@earendil-works/pi-durable").ConversationDocToken<RunState>;
|
|
25
|
+
/** Where a run is: its conversation, one document per run id, so a run started again finds the one it started. */
|
|
26
|
+
export declare const RunIndex: import("@earendil-works/pi-durable").SessionDocFamilyToken<{
|
|
27
|
+
conversationId: ConversationId | null;
|
|
28
|
+
}, null>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineDoc, defineDocFamily, } from "@earendil-works/pi-durable";
|
|
2
|
+
/** The run's own document, written in the commit that makes its conversation. */
|
|
3
|
+
export const RunDoc = defineDoc({
|
|
4
|
+
kind: "que-pasa.run",
|
|
5
|
+
version: 1,
|
|
6
|
+
scope: "conversation",
|
|
7
|
+
history: "latest",
|
|
8
|
+
fork: "initial",
|
|
9
|
+
initial: () => ({ runId: "", agent: "", model: "", caller: { id: "" } }),
|
|
10
|
+
checkpointWhen: () => true,
|
|
11
|
+
});
|
|
12
|
+
/** Where a run is: its conversation, one document per run id, so a run started again finds the one it started. */
|
|
13
|
+
export const RunIndex = defineDocFamily({
|
|
14
|
+
kind: "que-pasa.run-index",
|
|
15
|
+
version: 1,
|
|
16
|
+
scope: "session",
|
|
17
|
+
family: true,
|
|
18
|
+
initial: () => ({ conversationId: null }),
|
|
19
|
+
checkpointWhen: () => true,
|
|
20
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A run's trace: OpenTelemetry spans in the GenAI conventions (semconv 1.43.0), made live from pi's hooks, through the
|
|
3
|
+
* OpenTelemetry API only. The host owns the SDK and the exporter; with none, every call here is a no-op.
|
|
4
|
+
*
|
|
5
|
+
* invoke_agent <agent> one per process that works on the run: a segment
|
|
6
|
+
* ├─ chat <model> one per model request
|
|
7
|
+
* ├─ execute_tool <tool> one per tool call, submit included
|
|
8
|
+
* └─ que_pasa.reminder an event per reminder the run sent
|
|
9
|
+
*
|
|
10
|
+
* A segment's parent is the trace parent its run document keeps: the host's span when the run was started in one, or
|
|
11
|
+
* else the run's first segment. So every segment of a run, also one that resumes after a crash in a new process, is in
|
|
12
|
+
* one trace. Message content is recorded only when the host asks for it.
|
|
13
|
+
*/
|
|
14
|
+
import { type ConversationId, type Extension, type Harness } from "@earendil-works/pi-durable";
|
|
15
|
+
import { type AnyAgent } from "./extension.ts";
|
|
16
|
+
import { type TraceParent } from "./state.ts";
|
|
17
|
+
/** The trace extension's name: every run's conversation selects it, after its agent's. */
|
|
18
|
+
export declare const traceExtensionName = "que-pasa.trace";
|
|
19
|
+
/** The trace parent to keep for a run started now: the host's active span, if it has one. */
|
|
20
|
+
export declare function activeParent(): TraceParent | undefined;
|
|
21
|
+
/** The tracing of one runtime: its pi extension, and the segments of the runs it works on in this process. */
|
|
22
|
+
export declare function createTracing({ agents, captureContent, }: {
|
|
23
|
+
agents: ReadonlyMap<string, AnyAgent>;
|
|
24
|
+
captureContent: boolean;
|
|
25
|
+
}): {
|
|
26
|
+
extension: Extension;
|
|
27
|
+
/** The harness the segments read and wait on: bound once it is open, before any run goes on. */
|
|
28
|
+
bind(on: Harness): void;
|
|
29
|
+
/** A run this process started, so its first segment is not a resumed one. */
|
|
30
|
+
start(id: ConversationId): void;
|
|
31
|
+
/** Settles when the run's segment in this process, if it has one, has ended. */
|
|
32
|
+
ended(id: ConversationId): Promise<void>;
|
|
33
|
+
/** End every open segment: the runs are kept, and go on in the next process, in a segment of their own. */
|
|
34
|
+
closeAll(): Promise<void>;
|
|
35
|
+
};
|
|
36
|
+
/** The hash of what makes the agent what it is on this model: its instructions, its tools, its output and the model. */
|
|
37
|
+
export declare function versionOf(agent: AnyAgent, model: string): string;
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A run's trace: OpenTelemetry spans in the GenAI conventions (semconv 1.43.0), made live from pi's hooks, through the
|
|
3
|
+
* OpenTelemetry API only. The host owns the SDK and the exporter; with none, every call here is a no-op.
|
|
4
|
+
*
|
|
5
|
+
* invoke_agent <agent> one per process that works on the run: a segment
|
|
6
|
+
* ├─ chat <model> one per model request
|
|
7
|
+
* ├─ execute_tool <tool> one per tool call, submit included
|
|
8
|
+
* └─ que_pasa.reminder an event per reminder the run sent
|
|
9
|
+
*
|
|
10
|
+
* A segment's parent is the trace parent its run document keeps: the host's span when the run was started in one, or
|
|
11
|
+
* else the run's first segment. So every segment of a run, also one that resumes after a crash in a new process, is in
|
|
12
|
+
* one trace. Message content is recorded only when the host asks for it.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { BACKGROUND_CONTEXT, } from "@earendil-works/chord/context";
|
|
16
|
+
import { defineExtension, GenerationTask, hook, ToolTask, UsageDoc, } from "@earendil-works/pi-durable";
|
|
17
|
+
import { context as otel, diag, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
|
|
18
|
+
import { ATTR_ERROR_TYPE } from "@opentelemetry/semantic-conventions";
|
|
19
|
+
import { ATTR_ENDUSER_ID, ATTR_GEN_AI_AGENT_NAME, ATTR_GEN_AI_AGENT_VERSION, ATTR_GEN_AI_CONVERSATION_ID, ATTR_GEN_AI_INPUT_MESSAGES, ATTR_GEN_AI_OPERATION_NAME, ATTR_GEN_AI_OUTPUT_MESSAGES, ATTR_GEN_AI_PROVIDER_NAME, ATTR_GEN_AI_REQUEST_MODEL, ATTR_GEN_AI_RESPONSE_FINISH_REASONS, ATTR_GEN_AI_RESPONSE_ID, ATTR_GEN_AI_RESPONSE_MODEL, ATTR_GEN_AI_SYSTEM_INSTRUCTIONS, ATTR_GEN_AI_TOOL_CALL_ARGUMENTS, ATTR_GEN_AI_TOOL_CALL_ID, ATTR_GEN_AI_TOOL_CALL_RESULT, ATTR_GEN_AI_TOOL_DEFINITIONS, ATTR_GEN_AI_TOOL_NAME, ATTR_GEN_AI_TOOL_TYPE, ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, ATTR_GEN_AI_USAGE_INPUT_TOKENS, ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS, GEN_AI_OPERATION_NAME_VALUE_CHAT, GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL, GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT, } from "@opentelemetry/semantic-conventions/incubating";
|
|
20
|
+
import { parseModel } from "../define/index.js";
|
|
21
|
+
import { jsonSchema, reminder, submitTool, } from "./extension.js";
|
|
22
|
+
import { RunDoc } from "./state.js";
|
|
23
|
+
import { messagesOf, resultOf } from "./transcript.js";
|
|
24
|
+
/** The trace extension's name: every run's conversation selects it, after its agent's. */
|
|
25
|
+
export const traceExtensionName = "que-pasa.trace";
|
|
26
|
+
const tracer = trace.getTracer("que-pasa");
|
|
27
|
+
/** The trace parent to keep for a run started now: the host's active span, if it has one. */
|
|
28
|
+
export function activeParent() {
|
|
29
|
+
const span = trace.getSpanContext(otel.active());
|
|
30
|
+
return span && trace.isSpanContextValid(span) ? parentOf(span) : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** The tracing of one runtime: its pi extension, and the segments of the runs it works on in this process. */
|
|
33
|
+
export function createTracing({ agents, captureContent, }) {
|
|
34
|
+
let harness;
|
|
35
|
+
/** The runs this process started: a segment of any other run is a resumed one. */
|
|
36
|
+
const started = new Set();
|
|
37
|
+
const segments = new Map();
|
|
38
|
+
/** The run's segment in this process, opened on first use. Undefined for a conversation that is no run's. */
|
|
39
|
+
function segmentOf(id, reader, context) {
|
|
40
|
+
let segment = segments.get(id);
|
|
41
|
+
if (!segment) {
|
|
42
|
+
segment = open(id, reader, context);
|
|
43
|
+
segments.set(id, segment);
|
|
44
|
+
}
|
|
45
|
+
return segment;
|
|
46
|
+
}
|
|
47
|
+
async function open(id, reader, context) {
|
|
48
|
+
const run = await reader.snapshot(RunDoc, id, context);
|
|
49
|
+
const agent = run && agents.get(run.agent);
|
|
50
|
+
if (!run || !agent || !harness)
|
|
51
|
+
return undefined;
|
|
52
|
+
const parent = run.trace
|
|
53
|
+
? trace.setSpanContext(ROOT_CONTEXT, { ...run.trace, isRemote: true })
|
|
54
|
+
: ROOT_CONTEXT;
|
|
55
|
+
const span = tracer.startSpan(`${GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT} ${agent.name}`, {
|
|
56
|
+
kind: SpanKind.INTERNAL,
|
|
57
|
+
attributes: runAttributes(agent, run, !started.has(id), captureContent),
|
|
58
|
+
}, parent);
|
|
59
|
+
const segment = { span, tools: new Map(), ended: false };
|
|
60
|
+
// The run's first segment is the parent of the ones after it, when the run has no host span to keep.
|
|
61
|
+
if (!run.trace && trace.isSpanContextValid(span.spanContext())) {
|
|
62
|
+
await harness.commit(async (tx) => {
|
|
63
|
+
(await tx.doc(RunDoc, id)).trace = parentOf(span.spanContext());
|
|
64
|
+
}, BACKGROUND_CONTEXT);
|
|
65
|
+
}
|
|
66
|
+
segment.done = endWhenSettled(harness, id, run.runId, agent, segment);
|
|
67
|
+
return segment;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The segment's one owner of its end: the run's settlement, with its usage, its status and, opted in, its input and
|
|
71
|
+
* output. A run whose agent has an output and ended with no submit failed, as run() reports it.
|
|
72
|
+
*/
|
|
73
|
+
async function endWhenSettled(on, id, runId, agent, segment) {
|
|
74
|
+
try {
|
|
75
|
+
const record = await on.commit((tx) => tx.submissionByRequest(id, runId), BACKGROUND_CONTEXT);
|
|
76
|
+
const submission = record && (await on.submission(record.id, BACKGROUND_CONTEXT));
|
|
77
|
+
const settled = await submission?.wait(BACKGROUND_CONTEXT);
|
|
78
|
+
const conversation = await on.conversation(id, BACKGROUND_CONTEXT);
|
|
79
|
+
if (!settled || !conversation || segment.ended)
|
|
80
|
+
return;
|
|
81
|
+
const messages = await messagesOf(conversation, BACKGROUND_CONTEXT);
|
|
82
|
+
const spent = Object.values((await on.snapshot(UsageDoc, id, BACKGROUND_CONTEXT))?.models ?? {});
|
|
83
|
+
const total = (count) => spent.reduce((sum, usage) => sum + count(usage), 0);
|
|
84
|
+
segment.span.setAttributes({
|
|
85
|
+
[ATTR_GEN_AI_USAGE_INPUT_TOKENS]: total((u) => u.input + u.cacheRead + u.cacheWrite),
|
|
86
|
+
[ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: total((u) => u.output),
|
|
87
|
+
[ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]: total((u) => u.cacheRead),
|
|
88
|
+
...(captureContent ? runContent(messages) : {}),
|
|
89
|
+
});
|
|
90
|
+
const failure = settled.status !== "done"
|
|
91
|
+
? settled.reason
|
|
92
|
+
: agent.output && !resultOf(messages, submitTool)
|
|
93
|
+
? "no_submit"
|
|
94
|
+
: undefined;
|
|
95
|
+
end(segment, failure);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
// A close ends the wait; closeAll has ended the segment.
|
|
99
|
+
if (!segment.ended)
|
|
100
|
+
diag.error("que-pasa: a run's trace could not end", error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** End a segment and anything still open in it, with the run's failure as the error, if it failed. */
|
|
104
|
+
function end(segment, failure) {
|
|
105
|
+
segment.ended = true;
|
|
106
|
+
for (const open of [segment.chat, ...segment.tools.values()]) {
|
|
107
|
+
if (open)
|
|
108
|
+
fail(open, failure ?? "run_ended").end();
|
|
109
|
+
}
|
|
110
|
+
if (failure)
|
|
111
|
+
fail(segment.span, failure);
|
|
112
|
+
segment.span.end();
|
|
113
|
+
}
|
|
114
|
+
const traced = defineExtension({
|
|
115
|
+
name: traceExtensionName,
|
|
116
|
+
hooks: [
|
|
117
|
+
hook(GenerationTask, {
|
|
118
|
+
beforeRequest: (request, api, context) => safely(async () => {
|
|
119
|
+
const segment = await segmentOf(api.conversationId, api, context);
|
|
120
|
+
if (!segment)
|
|
121
|
+
return;
|
|
122
|
+
// A request that never answered: an attempt the abort or a retry left behind.
|
|
123
|
+
if (segment.chat)
|
|
124
|
+
fail(segment.chat, "superseded").end();
|
|
125
|
+
const last = request.messages.at(-1);
|
|
126
|
+
if (last?.role === "user" && textOf(last.content) === reminder)
|
|
127
|
+
segment.span.addEvent("que_pasa.reminder");
|
|
128
|
+
const run = await api.snapshot(RunDoc, api.conversationId, context);
|
|
129
|
+
const model = parseModel(run?.model ?? "");
|
|
130
|
+
segment.chat = tracer.startSpan(`${GEN_AI_OPERATION_NAME_VALUE_CHAT} ${model.modelId}`, {
|
|
131
|
+
kind: SpanKind.CLIENT,
|
|
132
|
+
attributes: {
|
|
133
|
+
[ATTR_GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_CHAT,
|
|
134
|
+
[ATTR_GEN_AI_PROVIDER_NAME]: model.provider,
|
|
135
|
+
[ATTR_GEN_AI_REQUEST_MODEL]: model.modelId,
|
|
136
|
+
...(captureContent
|
|
137
|
+
? {
|
|
138
|
+
[ATTR_GEN_AI_INPUT_MESSAGES]: json(request.messages.map(toGenAi)),
|
|
139
|
+
}
|
|
140
|
+
: {}),
|
|
141
|
+
},
|
|
142
|
+
}, trace.setSpan(ROOT_CONTEXT, segment.span));
|
|
143
|
+
}),
|
|
144
|
+
afterResponse: (message, api, context) => safely(async () => {
|
|
145
|
+
const segment = await segmentOf(api.conversationId, api, context);
|
|
146
|
+
if (!segment?.chat)
|
|
147
|
+
return;
|
|
148
|
+
segment.chat.setAttributes(responseAttributes(message, captureContent));
|
|
149
|
+
if (message.stopReason === "error" ||
|
|
150
|
+
message.stopReason === "aborted")
|
|
151
|
+
fail(segment.chat, message.stopReason);
|
|
152
|
+
segment.chat.end();
|
|
153
|
+
delete segment.chat;
|
|
154
|
+
}),
|
|
155
|
+
}),
|
|
156
|
+
hook(ToolTask, {
|
|
157
|
+
beforeTool: (call, api, context) => safely(async () => {
|
|
158
|
+
const segment = await segmentOf(api.conversationId, api, context);
|
|
159
|
+
if (segment)
|
|
160
|
+
segment.tools.set(call.id, toolSpan(segment, call, captureContent));
|
|
161
|
+
}),
|
|
162
|
+
afterTool: (call, result, api, context) => safely(async () => {
|
|
163
|
+
const segment = await segmentOf(api.conversationId, api, context);
|
|
164
|
+
if (!segment)
|
|
165
|
+
return;
|
|
166
|
+
// A result pi wrote with no call in this process, such as an interrupted one after a resume, starts here.
|
|
167
|
+
const span = segment.tools.get(call.id) ??
|
|
168
|
+
toolSpan(segment, call, captureContent);
|
|
169
|
+
segment.tools.delete(call.id);
|
|
170
|
+
if (captureContent)
|
|
171
|
+
span.setAttribute(ATTR_GEN_AI_TOOL_CALL_RESULT, textOf(result.output ?? []));
|
|
172
|
+
if (result.isError)
|
|
173
|
+
fail(span, result.diagnostics?.[0]?.code ?? "tool_error");
|
|
174
|
+
span.end();
|
|
175
|
+
}),
|
|
176
|
+
}),
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
extension: traced,
|
|
181
|
+
/** The harness the segments read and wait on: bound once it is open, before any run goes on. */
|
|
182
|
+
bind(on) {
|
|
183
|
+
harness = on;
|
|
184
|
+
},
|
|
185
|
+
/** A run this process started, so its first segment is not a resumed one. */
|
|
186
|
+
start(id) {
|
|
187
|
+
started.add(id);
|
|
188
|
+
},
|
|
189
|
+
/** Settles when the run's segment in this process, if it has one, has ended. */
|
|
190
|
+
async ended(id) {
|
|
191
|
+
await (await segments.get(id))?.done;
|
|
192
|
+
},
|
|
193
|
+
/** End every open segment: the runs are kept, and go on in the next process, in a segment of their own. */
|
|
194
|
+
async closeAll() {
|
|
195
|
+
for (const pending of segments.values()) {
|
|
196
|
+
const segment = await pending;
|
|
197
|
+
if (segment && !segment.ended) {
|
|
198
|
+
segment.span.addEvent("que_pasa.closed");
|
|
199
|
+
end(segment, undefined);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
segments.clear();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** The attributes of a run's invoke_agent span: what the run is, and, opted in, what its agent was given. */
|
|
207
|
+
function runAttributes(agent, run, resumed, captureContent) {
|
|
208
|
+
const model = parseModel(run.model);
|
|
209
|
+
const tools = toolDefinitions(agent);
|
|
210
|
+
return {
|
|
211
|
+
[ATTR_GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT,
|
|
212
|
+
[ATTR_GEN_AI_AGENT_NAME]: agent.name,
|
|
213
|
+
[ATTR_GEN_AI_AGENT_VERSION]: versionOf(agent, run.model),
|
|
214
|
+
[ATTR_GEN_AI_CONVERSATION_ID]: run.runId,
|
|
215
|
+
[ATTR_GEN_AI_PROVIDER_NAME]: model.provider,
|
|
216
|
+
[ATTR_GEN_AI_REQUEST_MODEL]: model.modelId,
|
|
217
|
+
[ATTR_ENDUSER_ID]: run.caller.id,
|
|
218
|
+
"que_pasa.resumed": resumed,
|
|
219
|
+
...(captureContent
|
|
220
|
+
? {
|
|
221
|
+
[ATTR_GEN_AI_SYSTEM_INSTRUCTIONS]: json([
|
|
222
|
+
{ type: "text", content: agent.instructions },
|
|
223
|
+
]),
|
|
224
|
+
[ATTR_GEN_AI_TOOL_DEFINITIONS]: json(tools),
|
|
225
|
+
}
|
|
226
|
+
: {}),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** The tools as the model is given them: the agent's, and submit when it has an output. */
|
|
230
|
+
function toolDefinitions(agent) {
|
|
231
|
+
const tools = agent.tools.map((tool) => ({
|
|
232
|
+
type: "function",
|
|
233
|
+
name: tool.name,
|
|
234
|
+
description: tool.description,
|
|
235
|
+
parameters: jsonSchema(tool.schema),
|
|
236
|
+
}));
|
|
237
|
+
return agent.output
|
|
238
|
+
? [
|
|
239
|
+
...tools,
|
|
240
|
+
{
|
|
241
|
+
type: "function",
|
|
242
|
+
name: submitTool,
|
|
243
|
+
parameters: jsonSchema(agent.output),
|
|
244
|
+
},
|
|
245
|
+
]
|
|
246
|
+
: tools;
|
|
247
|
+
}
|
|
248
|
+
/** The hash of what makes the agent what it is on this model: its instructions, its tools, its output and the model. */
|
|
249
|
+
export function versionOf(agent, model) {
|
|
250
|
+
const parts = {
|
|
251
|
+
instructions: agent.instructions,
|
|
252
|
+
tools: toolDefinitions(agent),
|
|
253
|
+
model,
|
|
254
|
+
};
|
|
255
|
+
return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
256
|
+
}
|
|
257
|
+
/** A run's input and output, opted in: its first user message, and its submit's arguments or its last answer. */
|
|
258
|
+
function runContent(messages) {
|
|
259
|
+
const input = messages.find((message) => message.role === "user");
|
|
260
|
+
const output = messages.findLast((message) => message.role === "assistant");
|
|
261
|
+
return {
|
|
262
|
+
...(input ? { [ATTR_GEN_AI_INPUT_MESSAGES]: json([toGenAi(input)]) } : {}),
|
|
263
|
+
...(output
|
|
264
|
+
? { [ATTR_GEN_AI_OUTPUT_MESSAGES]: json([toGenAi(output)]) }
|
|
265
|
+
: {}),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
function responseAttributes(message, captureContent) {
|
|
269
|
+
return {
|
|
270
|
+
[ATTR_GEN_AI_RESPONSE_MODEL]: message.model,
|
|
271
|
+
...(message.responseId
|
|
272
|
+
? { [ATTR_GEN_AI_RESPONSE_ID]: message.responseId }
|
|
273
|
+
: {}),
|
|
274
|
+
[ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: [message.stopReason],
|
|
275
|
+
// The conventions count input tokens with the cached ones in; pi-ai counts them apart.
|
|
276
|
+
[ATTR_GEN_AI_USAGE_INPUT_TOKENS]: message.usage.input + message.usage.cacheRead + message.usage.cacheWrite,
|
|
277
|
+
[ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: message.usage.output,
|
|
278
|
+
[ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]: message.usage.cacheRead,
|
|
279
|
+
...(message.usage.reasoning === undefined
|
|
280
|
+
? {}
|
|
281
|
+
: {
|
|
282
|
+
[ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS]: message.usage.reasoning,
|
|
283
|
+
}),
|
|
284
|
+
...(captureContent
|
|
285
|
+
? { [ATTR_GEN_AI_OUTPUT_MESSAGES]: json([toGenAi(message)]) }
|
|
286
|
+
: {}),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function toolSpan(segment, call, captureContent) {
|
|
290
|
+
return tracer.startSpan(`${GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL} ${call.name}`, {
|
|
291
|
+
kind: SpanKind.INTERNAL,
|
|
292
|
+
attributes: {
|
|
293
|
+
[ATTR_GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL,
|
|
294
|
+
[ATTR_GEN_AI_TOOL_NAME]: call.name,
|
|
295
|
+
[ATTR_GEN_AI_TOOL_CALL_ID]: call.id,
|
|
296
|
+
[ATTR_GEN_AI_TOOL_TYPE]: "function",
|
|
297
|
+
...(captureContent
|
|
298
|
+
? { [ATTR_GEN_AI_TOOL_CALL_ARGUMENTS]: json(call.arguments) }
|
|
299
|
+
: {}),
|
|
300
|
+
},
|
|
301
|
+
}, trace.setSpan(ROOT_CONTEXT, segment.span));
|
|
302
|
+
}
|
|
303
|
+
/** Mark a span failed, with the reason as its error type. */
|
|
304
|
+
function fail(span, reason) {
|
|
305
|
+
span.setAttribute(ATTR_ERROR_TYPE, reason);
|
|
306
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: reason });
|
|
307
|
+
return span;
|
|
308
|
+
}
|
|
309
|
+
/** Run a hook's tracing so that it never fails the run: pi blocks a tool call whose beforeTool throws. */
|
|
310
|
+
async function safely(work) {
|
|
311
|
+
try {
|
|
312
|
+
await work();
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
diag.error("que-pasa: tracing failed", error);
|
|
316
|
+
}
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
function parentOf(span) {
|
|
320
|
+
return {
|
|
321
|
+
traceId: span.traceId,
|
|
322
|
+
spanId: span.spanId,
|
|
323
|
+
traceFlags: span.traceFlags,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const json = (value) => JSON.stringify(value);
|
|
327
|
+
function textOf(content) {
|
|
328
|
+
return typeof content === "string"
|
|
329
|
+
? content
|
|
330
|
+
: content
|
|
331
|
+
.map((block) => block.type === "text" ? block.text : `[${block.mimeType}]`)
|
|
332
|
+
.join("");
|
|
333
|
+
}
|
|
334
|
+
/** A pi-ai message as the GenAI conventions write it. */
|
|
335
|
+
function toGenAi(message) {
|
|
336
|
+
switch (message.role) {
|
|
337
|
+
case "user":
|
|
338
|
+
return {
|
|
339
|
+
role: "user",
|
|
340
|
+
parts: [{ type: "text", content: textOf(message.content) }],
|
|
341
|
+
};
|
|
342
|
+
case "assistant":
|
|
343
|
+
return {
|
|
344
|
+
role: "assistant",
|
|
345
|
+
parts: message.content.map((block) => {
|
|
346
|
+
if (block.type === "text")
|
|
347
|
+
return { type: "text", content: block.text };
|
|
348
|
+
if (block.type === "thinking")
|
|
349
|
+
return { type: "reasoning", content: block.thinking };
|
|
350
|
+
return {
|
|
351
|
+
type: "tool_call",
|
|
352
|
+
id: block.id,
|
|
353
|
+
name: block.name,
|
|
354
|
+
arguments: block.arguments,
|
|
355
|
+
};
|
|
356
|
+
}),
|
|
357
|
+
finish_reason: message.stopReason,
|
|
358
|
+
};
|
|
359
|
+
case "toolResult":
|
|
360
|
+
return {
|
|
361
|
+
role: "tool",
|
|
362
|
+
parts: [
|
|
363
|
+
{
|
|
364
|
+
type: "tool_call_response",
|
|
365
|
+
id: message.toolCallId,
|
|
366
|
+
response: textOf(message.content),
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
};
|
|
370
|
+
default:
|
|
371
|
+
return { role: message.role, parts: [] };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Context } from "@earendil-works/chord/context";
|
|
2
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { Cursor, EntryQuery, EntryRecord, Page } from "@earendil-works/pi-durable";
|
|
4
|
+
/** A reader of one conversation's raw history: a conversation, or a hook's api. */
|
|
5
|
+
type Entries = {
|
|
6
|
+
entries(query: Omit<EntryQuery, "conversationId">, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<EntryRecord, Cursor>>;
|
|
7
|
+
};
|
|
8
|
+
/** The conversation's messages, oldest first, without the system entries: every page of its raw history. */
|
|
9
|
+
export declare function messagesOf(reader: Entries, context: Context): Promise<Message[]>;
|
|
10
|
+
/** The text of a user message. */
|
|
11
|
+
export declare function userText(message: Message): string | undefined;
|
|
12
|
+
/** The text of the last assistant message: the answer of an agent with no output. */
|
|
13
|
+
export declare function answerOf(messages: readonly Message[]): string;
|
|
14
|
+
/** The details of the last successful result of the tool `name`: where the submit tool keeps the run's output. */
|
|
15
|
+
export declare function resultOf(messages: readonly Message[], name: string): {
|
|
16
|
+
value: unknown;
|
|
17
|
+
} | undefined;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** The conversation's messages, oldest first, without the system entries: every page of its raw history. */
|
|
2
|
+
export async function messagesOf(reader, context) {
|
|
3
|
+
const messages = [];
|
|
4
|
+
let cursor;
|
|
5
|
+
do {
|
|
6
|
+
const page = await reader.entries({ order: "ascending" }, 100, cursor, context);
|
|
7
|
+
for (const entry of page.items) {
|
|
8
|
+
for (const message of entry.model ?? []) {
|
|
9
|
+
if (message.role !== "system")
|
|
10
|
+
messages.push(message);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
cursor = page.next;
|
|
14
|
+
} while (cursor !== undefined);
|
|
15
|
+
return messages;
|
|
16
|
+
}
|
|
17
|
+
/** The text of a user message. */
|
|
18
|
+
export function userText(message) {
|
|
19
|
+
if (message.role !== "user")
|
|
20
|
+
return undefined;
|
|
21
|
+
const { content } = message;
|
|
22
|
+
return typeof content === "string"
|
|
23
|
+
? content
|
|
24
|
+
: content
|
|
25
|
+
.flatMap((block) => (block.type === "text" ? [block.text] : []))
|
|
26
|
+
.join("");
|
|
27
|
+
}
|
|
28
|
+
/** The text of the last assistant message: the answer of an agent with no output. */
|
|
29
|
+
export function answerOf(messages) {
|
|
30
|
+
const answer = messages.findLast((message) => message.role === "assistant");
|
|
31
|
+
if (!answer || answer.role !== "assistant")
|
|
32
|
+
return "";
|
|
33
|
+
return answer.content
|
|
34
|
+
.flatMap((block) => (block.type === "text" ? [block.text] : []))
|
|
35
|
+
.join("");
|
|
36
|
+
}
|
|
37
|
+
/** The details of the last successful result of the tool `name`: where the submit tool keeps the run's output. */
|
|
38
|
+
export function resultOf(messages, name) {
|
|
39
|
+
const result = messages.findLast((message) => message.role === "toolResult" &&
|
|
40
|
+
message.toolName === name &&
|
|
41
|
+
!message.isError);
|
|
42
|
+
return result?.role === "toolResult" ? { value: result.details } : undefined;
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mku1tra/que-pasa",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An agent harness on pi-durable: define agents and tools with define/, and run them durably and traced with loop/.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/matta-kelly/que-pasa.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"packageManager": "npm@11.21.0",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22.18"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
"./define": {
|
|
17
|
+
"types": "./dist/define/index.d.ts",
|
|
18
|
+
"default": "./dist/define/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./loop": {
|
|
21
|
+
"types": "./dist/loop/index.d.ts",
|
|
22
|
+
"default": "./dist/loop/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@earendil-works/chord": "1.5.0",
|
|
36
|
+
"@earendil-works/pi-ai": "1.5.0",
|
|
37
|
+
"@earendil-works/pi-durable": "1.5.0",
|
|
38
|
+
"@opentelemetry/api": "1.10.0",
|
|
39
|
+
"@opentelemetry/semantic-conventions": "1.43.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"zod": "^4.6.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@eslint/js": "10.0.1",
|
|
46
|
+
"@mku1tra/basis": "^0.30.0",
|
|
47
|
+
"@mku1tra/it": "^0.9.0",
|
|
48
|
+
"@opentelemetry/context-async-hooks": "3.0.0",
|
|
49
|
+
"@opentelemetry/sdk-trace": "3.0.0",
|
|
50
|
+
"@types/node": "22.20.5",
|
|
51
|
+
"eslint": "10.12.0",
|
|
52
|
+
"eslint-plugin-sonarjs": "4.2.2",
|
|
53
|
+
"globals": "17.13.0",
|
|
54
|
+
"jiti": "2.7.0",
|
|
55
|
+
"prettier": "3.9.10",
|
|
56
|
+
"typescript": "6.0.3",
|
|
57
|
+
"typescript-eslint": "8.71.2",
|
|
58
|
+
"vite": "8.3.4",
|
|
59
|
+
"vitest": "5.0.4",
|
|
60
|
+
"zod": "4.6.7"
|
|
61
|
+
}
|
|
62
|
+
}
|