@agentic-kit/pi-host 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +129 -0
- package/esm/events.d.ts +47 -0
- package/esm/events.js +161 -0
- package/esm/gate.d.ts +35 -0
- package/esm/gate.js +88 -0
- package/esm/host.d.ts +18 -0
- package/esm/host.js +26 -0
- package/esm/index.d.ts +16 -0
- package/esm/index.js +12 -0
- package/esm/model.d.ts +39 -0
- package/esm/model.js +211 -0
- package/esm/persona.d.ts +85 -0
- package/esm/persona.js +122 -0
- package/esm/project-context.d.ts +22 -0
- package/esm/project-context.js +50 -0
- package/esm/tools.d.ts +23 -0
- package/esm/tools.js +32 -0
- package/esm/workspace-tools.d.ts +22 -0
- package/esm/workspace-tools.js +199 -0
- package/events.d.ts +47 -0
- package/events.js +168 -0
- package/gate.d.ts +35 -0
- package/gate.js +92 -0
- package/host.d.ts +18 -0
- package/host.js +29 -0
- package/index.d.ts +16 -0
- package/index.js +42 -0
- package/model.d.ts +39 -0
- package/model.js +216 -0
- package/package.json +49 -0
- package/persona.d.ts +85 -0
- package/persona.js +131 -0
- package/project-context.d.ts +22 -0
- package/project-context.js +59 -0
- package/tools.d.ts +23 -0
- package/tools.js +36 -0
- package/workspace-tools.d.ts +22 -0
- package/workspace-tools.js +207 -0
package/model.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { AssistantMessageEventStream, Context, ModelDescriptor, StreamOptions } from '@agentic-kit/chat';
|
|
2
|
+
/** Who the inference is billed to. Mirrors the http runtime's `AgentHeaders`. */
|
|
3
|
+
export interface MeteredIdentity {
|
|
4
|
+
databaseId: string;
|
|
5
|
+
entityId?: string | null;
|
|
6
|
+
organizationId?: string | null;
|
|
7
|
+
actorId?: string | null;
|
|
8
|
+
}
|
|
9
|
+
export interface MeteredModelOptions extends MeteredIdentity {
|
|
10
|
+
/** `AGENTIC_SERVER_URL` — the gateway's root, with or without `/v1`. */
|
|
11
|
+
agenticServerUrl: string;
|
|
12
|
+
/** The model id the persona selected. */
|
|
13
|
+
model: string;
|
|
14
|
+
/** Injectable for tests; defaults to the runtime's `fetch`. */
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
export interface MeteredModel {
|
|
18
|
+
model: ModelDescriptor;
|
|
19
|
+
/** `Agent`'s `streamFn`. */
|
|
20
|
+
streamFn: (model: ModelDescriptor, context: Context, options?: StreamOptions) => AssistantMessageEventStream;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The identity headers `agentic-server` meters against. `X-Database-Id` is
|
|
24
|
+
* required; the billing entity falls back the way the node runtime falls back,
|
|
25
|
+
* so a run launched without an entity still bills the actor's personal org.
|
|
26
|
+
*/
|
|
27
|
+
export declare function meteringHeaders(identity: MeteredIdentity): Record<string, string>;
|
|
28
|
+
/**
|
|
29
|
+
* The gateway's OpenAI-compatible api root, `<host>/v1`, whichever form the
|
|
30
|
+
* projection took.
|
|
31
|
+
*
|
|
32
|
+
* `AGENTIC_SERVER_URL` is documented as the bare root, but an operator reading a
|
|
33
|
+
* provider's own docs sets it with `/v1` — and `resolveMeteredGateway` accepts
|
|
34
|
+
* both, so the two halves of one stack disagreed: appending `/v1` unconditionally
|
|
35
|
+
* posted to `<host>/v1/v1/chat/completions`, which the gateway answers with a 404
|
|
36
|
+
* that surfaces as "no assistant message" naming no url.
|
|
37
|
+
*/
|
|
38
|
+
export declare function gatewayApiRoot(agenticServerUrl: string): string;
|
|
39
|
+
export declare function createMeteredModel(options: MeteredModelOptions): MeteredModel;
|
package/model.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The model a resource Job talks to: the tenant's metered gateway, never a
|
|
3
|
+
// provider.
|
|
4
|
+
//
|
|
5
|
+
// `agentic-server` is OpenAI-compatible and already deployed as the platform's
|
|
6
|
+
// `AGENTIC_SERVER_URL`, and the three identity headers it meters on are the
|
|
7
|
+
// ones `compute/runtimes/node/src/agent.ts` already sends. So the model
|
|
8
|
+
// descriptor is the agentic stack's own (`OpenAIAdapter.createModel`), pointed
|
|
9
|
+
// at that base URL, and nothing here holds a provider key: the gateway owns the
|
|
10
|
+
// credential, the quota and the ledger.
|
|
11
|
+
//
|
|
12
|
+
// The transport is the one thing that cannot be the adapter's. `OpenAIAdapter`
|
|
13
|
+
// only speaks SSE (`stream: true`), and the gateway is a request/response proxy
|
|
14
|
+
// — it reads the upstream answer with `response.json()` and meters the `usage`
|
|
15
|
+
// block, which a streamed answer does not carry. So `streamFn` below is a
|
|
16
|
+
// single non-streaming POST rendered as the event stream `Agent` consumes: one
|
|
17
|
+
// `text_start`/`text_delta`/`text_end` for the answer, one `toolcall_end` per
|
|
18
|
+
// tool call, then `done`. Every event the agent loop and the transcript writer
|
|
19
|
+
// need, and no second metering path.
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.meteringHeaders = meteringHeaders;
|
|
22
|
+
exports.gatewayApiRoot = gatewayApiRoot;
|
|
23
|
+
exports.createMeteredModel = createMeteredModel;
|
|
24
|
+
const chat_1 = require("@agentic-kit/chat");
|
|
25
|
+
/**
|
|
26
|
+
* The identity headers `agentic-server` meters against. `X-Database-Id` is
|
|
27
|
+
* required; the billing entity falls back the way the node runtime falls back,
|
|
28
|
+
* so a run launched without an entity still bills the actor's personal org.
|
|
29
|
+
*/
|
|
30
|
+
function meteringHeaders(identity) {
|
|
31
|
+
const headers = { 'X-Database-Id': identity.databaseId };
|
|
32
|
+
const billingEntity = identity.entityId ?? identity.organizationId ?? identity.actorId;
|
|
33
|
+
if (billingEntity)
|
|
34
|
+
headers['X-Entity-Id'] = billingEntity;
|
|
35
|
+
if (identity.actorId)
|
|
36
|
+
headers['X-Actor-Id'] = identity.actorId;
|
|
37
|
+
return headers;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The gateway's OpenAI-compatible api root, `<host>/v1`, whichever form the
|
|
41
|
+
* projection took.
|
|
42
|
+
*
|
|
43
|
+
* `AGENTIC_SERVER_URL` is documented as the bare root, but an operator reading a
|
|
44
|
+
* provider's own docs sets it with `/v1` — and `resolveMeteredGateway` accepts
|
|
45
|
+
* both, so the two halves of one stack disagreed: appending `/v1` unconditionally
|
|
46
|
+
* posted to `<host>/v1/v1/chat/completions`, which the gateway answers with a 404
|
|
47
|
+
* that surfaces as "no assistant message" naming no url.
|
|
48
|
+
*/
|
|
49
|
+
function gatewayApiRoot(agenticServerUrl) {
|
|
50
|
+
return `${agenticServerUrl.replace(/\/+$/, '').replace(/\/v1$/, '')}/v1`;
|
|
51
|
+
}
|
|
52
|
+
function createMeteredModel(options) {
|
|
53
|
+
const { agenticServerUrl, model } = options;
|
|
54
|
+
if (!agenticServerUrl) {
|
|
55
|
+
throw new Error('a coding run needs AGENTIC_SERVER_URL — inference goes through the metered ' +
|
|
56
|
+
'gateway, and a resource Job that cannot reach it has no provider of its own');
|
|
57
|
+
}
|
|
58
|
+
const headers = { 'Content-Type': 'application/json', ...meteringHeaders(options) };
|
|
59
|
+
const apiRoot = gatewayApiRoot(agenticServerUrl);
|
|
60
|
+
const url = `${apiRoot}/chat/completions`;
|
|
61
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
62
|
+
const adapter = new chat_1.OpenAIAdapter({
|
|
63
|
+
baseUrl: apiRoot,
|
|
64
|
+
provider: 'agentic-server',
|
|
65
|
+
headers
|
|
66
|
+
});
|
|
67
|
+
const streamFn = (descriptor, context, streamOptions) => {
|
|
68
|
+
const stream = (0, chat_1.createAssistantMessageEventStream)();
|
|
69
|
+
void (async () => {
|
|
70
|
+
const message = (0, chat_1.createAssistantMessage)(descriptor);
|
|
71
|
+
try {
|
|
72
|
+
stream.push({ type: 'start', partial: message });
|
|
73
|
+
const response = await doFetch(url, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers,
|
|
76
|
+
body: JSON.stringify(buildRequestBody(descriptor, context, streamOptions)),
|
|
77
|
+
...(streamOptions?.signal ? { signal: streamOptions.signal } : {})
|
|
78
|
+
});
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
const body = await response.text().catch(() => '');
|
|
81
|
+
throw new Error(`agentic server answered ${response.status}: ${body}`);
|
|
82
|
+
}
|
|
83
|
+
const completion = (await response.json());
|
|
84
|
+
const reason = finish(completion, applyCompletion(message, descriptor, completion, stream));
|
|
85
|
+
message.stopReason = reason;
|
|
86
|
+
stream.push({ type: 'done', reason, message });
|
|
87
|
+
stream.end(message);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
message.stopReason = streamOptions?.signal?.aborted ? 'aborted' : 'error';
|
|
91
|
+
message.errorMessage = error instanceof Error ? error.message : String(error);
|
|
92
|
+
stream.push({
|
|
93
|
+
type: 'error',
|
|
94
|
+
reason: message.stopReason === 'aborted' ? 'aborted' : 'error',
|
|
95
|
+
error: message
|
|
96
|
+
});
|
|
97
|
+
stream.end(message);
|
|
98
|
+
}
|
|
99
|
+
})();
|
|
100
|
+
return stream;
|
|
101
|
+
};
|
|
102
|
+
return { model: adapter.createModel(model), streamFn };
|
|
103
|
+
}
|
|
104
|
+
/** Fill the message from the completion and emit the events that describe it. */
|
|
105
|
+
function applyCompletion(message, descriptor, completion, stream) {
|
|
106
|
+
const choice = completion.choices?.[0];
|
|
107
|
+
const text = choice?.message?.content ?? '';
|
|
108
|
+
if (text) {
|
|
109
|
+
const contentIndex = message.content.length;
|
|
110
|
+
message.content.push({ type: 'text', text });
|
|
111
|
+
stream.push({ type: 'text_start', contentIndex, partial: message });
|
|
112
|
+
stream.push({ type: 'text_delta', contentIndex, delta: text, partial: message });
|
|
113
|
+
stream.push({ type: 'text_end', contentIndex, content: text, partial: message });
|
|
114
|
+
}
|
|
115
|
+
let calls = 0;
|
|
116
|
+
for (const raw of choice?.message?.tool_calls ?? []) {
|
|
117
|
+
const toolCall = toToolCall(raw, calls);
|
|
118
|
+
const contentIndex = message.content.length;
|
|
119
|
+
message.content.push(toolCall);
|
|
120
|
+
stream.push({ type: 'toolcall_start', contentIndex, partial: message });
|
|
121
|
+
if (toolCall.rawArguments) {
|
|
122
|
+
stream.push({ type: 'toolcall_delta', contentIndex, delta: toolCall.rawArguments, partial: message });
|
|
123
|
+
}
|
|
124
|
+
stream.push({ type: 'toolcall_end', contentIndex, toolCall, partial: message });
|
|
125
|
+
calls += 1;
|
|
126
|
+
}
|
|
127
|
+
const usage = completion.usage ?? {};
|
|
128
|
+
message.usage.input = usage.prompt_tokens ?? 0;
|
|
129
|
+
message.usage.output = usage.completion_tokens ?? 0;
|
|
130
|
+
message.usage.totalTokens =
|
|
131
|
+
usage.total_tokens ?? (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0);
|
|
132
|
+
(0, chat_1.calculateUsageCost)(descriptor, message.usage);
|
|
133
|
+
return calls > 0;
|
|
134
|
+
}
|
|
135
|
+
function toToolCall(raw, index) {
|
|
136
|
+
const toolCall = (0, chat_1.createToolCall)(raw.id ?? `call_${index}`, raw.function?.name ?? '');
|
|
137
|
+
const rawArguments = raw.function?.arguments ?? '';
|
|
138
|
+
if (rawArguments) {
|
|
139
|
+
toolCall.rawArguments = rawArguments;
|
|
140
|
+
// A model that answers with unparseable arguments is the model's error to
|
|
141
|
+
// recover from, so the call still reaches the agent — with no arguments,
|
|
142
|
+
// which the tool will reject — rather than ending the run here.
|
|
143
|
+
try {
|
|
144
|
+
toolCall.arguments = JSON.parse(rawArguments);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
toolCall.arguments = {};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return toolCall;
|
|
151
|
+
}
|
|
152
|
+
function finish(completion, hasToolCalls) {
|
|
153
|
+
if (hasToolCalls)
|
|
154
|
+
return 'toolUse';
|
|
155
|
+
return completion.choices?.[0]?.finish_reason === 'length' ? 'length' : 'stop';
|
|
156
|
+
}
|
|
157
|
+
function buildRequestBody(model, context, options) {
|
|
158
|
+
const body = {
|
|
159
|
+
model: model.id,
|
|
160
|
+
messages: [
|
|
161
|
+
...(context.systemPrompt ? [{ role: 'system', content: context.systemPrompt }] : []),
|
|
162
|
+
...context.messages.map(toOpenAIMessage)
|
|
163
|
+
]
|
|
164
|
+
};
|
|
165
|
+
if (options?.maxTokens !== undefined)
|
|
166
|
+
body.max_tokens = options.maxTokens;
|
|
167
|
+
if (options?.temperature !== undefined)
|
|
168
|
+
body.temperature = options.temperature;
|
|
169
|
+
if (context.tools && context.tools.length > 0) {
|
|
170
|
+
body.tools = context.tools.map((tool) => ({
|
|
171
|
+
type: 'function',
|
|
172
|
+
function: { name: tool.name, description: tool.description, parameters: tool.parameters }
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
return body;
|
|
176
|
+
}
|
|
177
|
+
function toOpenAIMessage(message) {
|
|
178
|
+
if (message.role === 'user') {
|
|
179
|
+
return {
|
|
180
|
+
role: 'user',
|
|
181
|
+
content: typeof message.content === 'string'
|
|
182
|
+
? message.content
|
|
183
|
+
: message.content
|
|
184
|
+
.map((block) => (block.type === 'text' ? block.text : `[image:${block.mimeType}]`))
|
|
185
|
+
.join('\n')
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (message.role === 'toolResult') {
|
|
189
|
+
return {
|
|
190
|
+
role: 'tool',
|
|
191
|
+
tool_call_id: message.toolCallId,
|
|
192
|
+
content: message.content
|
|
193
|
+
.map((block) => (block.type === 'text' ? block.text : `[image:${block.mimeType}]`))
|
|
194
|
+
.join('\n')
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const text = message.content
|
|
198
|
+
.filter((block) => block.type === 'text' || block.type === 'thinking')
|
|
199
|
+
.map((block) => (block.type === 'text' ? block.text : `<thinking>${block.thinking}</thinking>`))
|
|
200
|
+
.join('\n');
|
|
201
|
+
const toolCalls = message.content
|
|
202
|
+
.filter((block) => block.type === 'toolCall')
|
|
203
|
+
.map((block) => ({
|
|
204
|
+
id: block.id,
|
|
205
|
+
type: 'function',
|
|
206
|
+
function: {
|
|
207
|
+
name: block.name,
|
|
208
|
+
arguments: block.rawArguments ?? JSON.stringify(block.arguments)
|
|
209
|
+
}
|
|
210
|
+
}));
|
|
211
|
+
return {
|
|
212
|
+
role: 'assistant',
|
|
213
|
+
content: text.length > 0 ? text : null,
|
|
214
|
+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {})
|
|
215
|
+
};
|
|
216
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentic-kit/pi-host",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"author": "Constructive <developers@constructive.io>",
|
|
5
|
+
"description": "The host side of a headless agent run: a GateHost that asks for approval through the conversation thread, persona → model/prompt/tools selection, and an AgentEvent → transcript/task writer.",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"module": "esm/index.js",
|
|
8
|
+
"types": "index.d.ts",
|
|
9
|
+
"homepage": "https://github.com/constructive-io/constructive",
|
|
10
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public",
|
|
13
|
+
"directory": "dist"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/constructive-io/constructive"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/constructive-io/constructive/issues"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"clean": "makage clean",
|
|
24
|
+
"prepack": "npm run build",
|
|
25
|
+
"build": "makage build",
|
|
26
|
+
"build:dev": "makage build --dev",
|
|
27
|
+
"lint": "eslint . --fix",
|
|
28
|
+
"test": "jest",
|
|
29
|
+
"test:watch": "jest --watch"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"agentic-kit",
|
|
33
|
+
"pi",
|
|
34
|
+
"coding-agent",
|
|
35
|
+
"run-log",
|
|
36
|
+
"constructive"
|
|
37
|
+
],
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@agentic-kit/agent-conversation": "^0.2.0",
|
|
40
|
+
"@constructive-io/coerce": "^0.4.1"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@agentic-kit/agent": "^0.19.0",
|
|
44
|
+
"@agentic-kit/chat": "^1.18.0",
|
|
45
|
+
"@agentic-kit/harness": "^0.15.0",
|
|
46
|
+
"@agentic-kit/pi": "^0.22.2"
|
|
47
|
+
},
|
|
48
|
+
"gitHead": "9bba983b38395277e0400294aaa380e586b049d4"
|
|
49
|
+
}
|
package/persona.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { GraphQLClient } from '@agentic-kit/agent-conversation';
|
|
2
|
+
export interface PersonaRow {
|
|
3
|
+
id: string;
|
|
4
|
+
slug: string;
|
|
5
|
+
name: string;
|
|
6
|
+
systemPrompt: string | null;
|
|
7
|
+
resources: (string | null)[] | null;
|
|
8
|
+
config: PersonaConfig | null;
|
|
9
|
+
}
|
|
10
|
+
/** `agent_persona.config`, as this runner reads it. Unknown keys are ignored. */
|
|
11
|
+
export interface PersonaConfig {
|
|
12
|
+
model?: string;
|
|
13
|
+
temperature?: number;
|
|
14
|
+
/** Tool names the persona is allowed to use. Absent means "every tool the lane offers". */
|
|
15
|
+
tools?: string[];
|
|
16
|
+
/** Tool names withheld from the persona, applied after `tools`. */
|
|
17
|
+
excludeTools?: string[];
|
|
18
|
+
maxSteps?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface SkillResource {
|
|
21
|
+
slug: string;
|
|
22
|
+
title: string;
|
|
23
|
+
kind: string | null;
|
|
24
|
+
body: string;
|
|
25
|
+
}
|
|
26
|
+
export declare class PersonaNotFoundError extends Error {
|
|
27
|
+
readonly ref: string;
|
|
28
|
+
constructor(ref: string);
|
|
29
|
+
}
|
|
30
|
+
export declare class UnknownPersonaToolError extends Error {
|
|
31
|
+
readonly persona: string;
|
|
32
|
+
readonly missing: string[];
|
|
33
|
+
readonly available: string[];
|
|
34
|
+
constructor(persona: string, missing: string[], available: string[]);
|
|
35
|
+
}
|
|
36
|
+
export declare class PersonaModelUnresolvedError extends Error {
|
|
37
|
+
readonly persona: string;
|
|
38
|
+
constructor(persona: string);
|
|
39
|
+
}
|
|
40
|
+
export interface LoadPersonaInput {
|
|
41
|
+
client: GraphQLClient;
|
|
42
|
+
databaseId: string;
|
|
43
|
+
/** One of the two; the id wins when both are given. */
|
|
44
|
+
personaId?: string | null;
|
|
45
|
+
personaSlug?: string | null;
|
|
46
|
+
}
|
|
47
|
+
/** The persona row, or null when the run was given neither an id nor a slug. */
|
|
48
|
+
export declare function loadPersona(input: LoadPersonaInput): Promise<PersonaRow | null>;
|
|
49
|
+
/** The `agent_resource` rows a persona names, in the order it named them. */
|
|
50
|
+
export declare function loadPersonaSkills(input: {
|
|
51
|
+
client: GraphQLClient;
|
|
52
|
+
databaseId: string;
|
|
53
|
+
persona: PersonaRow;
|
|
54
|
+
}): Promise<SkillResource[]>;
|
|
55
|
+
export interface PersonaSelection {
|
|
56
|
+
personaId: string | null;
|
|
57
|
+
model: string;
|
|
58
|
+
systemPrompt: string;
|
|
59
|
+
temperature?: number;
|
|
60
|
+
maxSteps?: number;
|
|
61
|
+
/** Tool names, filtered by the persona's `tools`/`excludeTools`. */
|
|
62
|
+
toolNames: string[];
|
|
63
|
+
skills: SkillResource[];
|
|
64
|
+
}
|
|
65
|
+
export interface SelectPersonaInput {
|
|
66
|
+
persona: PersonaRow | null;
|
|
67
|
+
skills?: SkillResource[];
|
|
68
|
+
/** Every tool the lane offers, by name. */
|
|
69
|
+
availableToolNames: string[];
|
|
70
|
+
/**
|
|
71
|
+
* The deployment's own configured default, used when the persona names no
|
|
72
|
+
* model. Absent is legal and means "someone must choose": a run billed
|
|
73
|
+
* against a model nobody picked is worse than a run that refuses to start, so
|
|
74
|
+
* a selection with neither raises `PersonaModelUnresolvedError`.
|
|
75
|
+
*/
|
|
76
|
+
defaultModel?: string | undefined;
|
|
77
|
+
/** Used when the persona carries no system prompt. */
|
|
78
|
+
defaultSystemPrompt: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Resolve what the run should be configured with. Skill bodies are appended to
|
|
82
|
+
* the prompt — the harness's skill overlay materializes files for hosts that
|
|
83
|
+
* have a filesystem contract with the model; here the prompt is the contract.
|
|
84
|
+
*/
|
|
85
|
+
export declare function selectPersona(input: SelectPersonaInput): PersonaSelection;
|
package/persona.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Persona → model, prompt, tools, skills.
|
|
3
|
+
//
|
|
4
|
+
// The agent module already models all four: `agent_persona.system_prompt` is the
|
|
5
|
+
// prompt, `agent_persona.config` is the model/tool preferences, and
|
|
6
|
+
// `agent_persona.resources` names `agent_resource` rows (kind `skill` /
|
|
7
|
+
// `knowledge` / `convention`) by slug. The run reads them; it invents none of
|
|
8
|
+
// them, and a persona that names a tool the lane does not carry is an error
|
|
9
|
+
// rather than a silent narrowing — an operator who wrote `apply_migration` into
|
|
10
|
+
// a persona should hear that the coding lane does not offer it.
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.PersonaModelUnresolvedError = exports.UnknownPersonaToolError = exports.PersonaNotFoundError = void 0;
|
|
13
|
+
exports.loadPersona = loadPersona;
|
|
14
|
+
exports.loadPersonaSkills = loadPersonaSkills;
|
|
15
|
+
exports.selectPersona = selectPersona;
|
|
16
|
+
const PERSONA_FIELDS = 'id slug name systemPrompt resources config';
|
|
17
|
+
const PERSONA_BY_SLUG = `query CodeTaskPersonaBySlug($databaseId: UUID!, $slug: String!) {
|
|
18
|
+
agentPersonas(
|
|
19
|
+
where: { databaseId: { equalTo: $databaseId }, slug: { equalTo: $slug } }
|
|
20
|
+
first: 1
|
|
21
|
+
) { nodes { ${PERSONA_FIELDS} } }
|
|
22
|
+
}`;
|
|
23
|
+
const PERSONA_BY_ID = `query CodeTaskPersonaById($id: UUID!) {
|
|
24
|
+
agentPersona(id: $id) { ${PERSONA_FIELDS} }
|
|
25
|
+
}`;
|
|
26
|
+
const RESOURCES_BY_SLUG = `query CodeTaskPersonaResources($databaseId: UUID!, $slugs: [String!]) {
|
|
27
|
+
agentResources(
|
|
28
|
+
where: { databaseId: { equalTo: $databaseId }, slug: { in: $slugs }, isActive: { equalTo: true } }
|
|
29
|
+
) { nodes { slug title kind body } }
|
|
30
|
+
}`;
|
|
31
|
+
class PersonaNotFoundError extends Error {
|
|
32
|
+
ref;
|
|
33
|
+
constructor(ref) {
|
|
34
|
+
super(`no active agent persona for ${ref}`);
|
|
35
|
+
this.ref = ref;
|
|
36
|
+
this.name = 'PersonaNotFoundError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
exports.PersonaNotFoundError = PersonaNotFoundError;
|
|
40
|
+
class UnknownPersonaToolError extends Error {
|
|
41
|
+
persona;
|
|
42
|
+
missing;
|
|
43
|
+
available;
|
|
44
|
+
constructor(persona, missing, available) {
|
|
45
|
+
super(`persona ${persona} asks for tool(s) this lane does not offer: ${missing.join(', ')} — available: ${available.join(', ') || '(none)'}`);
|
|
46
|
+
this.persona = persona;
|
|
47
|
+
this.missing = missing;
|
|
48
|
+
this.available = available;
|
|
49
|
+
this.name = 'UnknownPersonaToolError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
exports.UnknownPersonaToolError = UnknownPersonaToolError;
|
|
53
|
+
class PersonaModelUnresolvedError extends Error {
|
|
54
|
+
persona;
|
|
55
|
+
constructor(persona) {
|
|
56
|
+
super(`no model for this run: persona ${persona} names none and the deployment ` +
|
|
57
|
+
'carries no default (CODE_TASK_MODEL) — a model this host invented would ' +
|
|
58
|
+
'be billed and refused by a gateway that never routes it');
|
|
59
|
+
this.persona = persona;
|
|
60
|
+
this.name = 'PersonaModelUnresolvedError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
exports.PersonaModelUnresolvedError = PersonaModelUnresolvedError;
|
|
64
|
+
/** The persona row, or null when the run was given neither an id nor a slug. */
|
|
65
|
+
async function loadPersona(input) {
|
|
66
|
+
const { client, databaseId, personaId, personaSlug } = input;
|
|
67
|
+
if (personaId) {
|
|
68
|
+
const data = await client.request(PERSONA_BY_ID, {
|
|
69
|
+
id: personaId
|
|
70
|
+
});
|
|
71
|
+
if (!data.agentPersona)
|
|
72
|
+
throw new PersonaNotFoundError(`id ${personaId}`);
|
|
73
|
+
return data.agentPersona;
|
|
74
|
+
}
|
|
75
|
+
if (personaSlug) {
|
|
76
|
+
const data = await client.request(PERSONA_BY_SLUG, { databaseId, slug: personaSlug });
|
|
77
|
+
const persona = data.agentPersonas?.nodes?.[0];
|
|
78
|
+
if (!persona)
|
|
79
|
+
throw new PersonaNotFoundError(`slug ${personaSlug}`);
|
|
80
|
+
return persona;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/** The `agent_resource` rows a persona names, in the order it named them. */
|
|
85
|
+
async function loadPersonaSkills(input) {
|
|
86
|
+
const slugs = (input.persona.resources ?? []).filter((slug) => Boolean(slug));
|
|
87
|
+
if (slugs.length === 0)
|
|
88
|
+
return [];
|
|
89
|
+
const data = await input.client.request(RESOURCES_BY_SLUG, { databaseId: input.databaseId, slugs });
|
|
90
|
+
const found = new Map((data.agentResources?.nodes ?? []).map((row) => [row.slug, row]));
|
|
91
|
+
const missing = slugs.filter((slug) => !found.has(slug));
|
|
92
|
+
if (missing.length > 0) {
|
|
93
|
+
throw new Error(`persona ${input.persona.slug} names agent_resource slug(s) that are absent or inactive: ${missing.join(', ')}`);
|
|
94
|
+
}
|
|
95
|
+
return slugs.map((slug) => found.get(slug));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve what the run should be configured with. Skill bodies are appended to
|
|
99
|
+
* the prompt — the harness's skill overlay materializes files for hosts that
|
|
100
|
+
* have a filesystem contract with the model; here the prompt is the contract.
|
|
101
|
+
*/
|
|
102
|
+
function selectPersona(input) {
|
|
103
|
+
const { persona, availableToolNames, defaultModel, defaultSystemPrompt } = input;
|
|
104
|
+
const config = persona?.config ?? {};
|
|
105
|
+
const skills = input.skills ?? [];
|
|
106
|
+
const requested = config.tools;
|
|
107
|
+
if (requested) {
|
|
108
|
+
const missing = requested.filter((name) => !availableToolNames.includes(name));
|
|
109
|
+
if (missing.length > 0) {
|
|
110
|
+
throw new UnknownPersonaToolError(persona?.slug ?? '(none)', missing, availableToolNames);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const excluded = new Set(config.excludeTools ?? []);
|
|
114
|
+
const toolNames = (requested ?? availableToolNames).filter((name) => !excluded.has(name));
|
|
115
|
+
const model = config.model ?? defaultModel;
|
|
116
|
+
if (!model)
|
|
117
|
+
throw new PersonaModelUnresolvedError(persona?.slug ?? '(none)');
|
|
118
|
+
const base = persona?.systemPrompt?.trim() || defaultSystemPrompt;
|
|
119
|
+
const systemPrompt = [base, ...skills.map((skill) => `## ${skill.title}\n\n${skill.body.trim()}`)]
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.join('\n\n');
|
|
122
|
+
return {
|
|
123
|
+
personaId: persona?.id ?? null,
|
|
124
|
+
model,
|
|
125
|
+
systemPrompt,
|
|
126
|
+
...(config.temperature === undefined ? {} : { temperature: config.temperature }),
|
|
127
|
+
...(config.maxSteps === undefined ? {} : { maxSteps: config.maxSteps }),
|
|
128
|
+
toolNames,
|
|
129
|
+
skills
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ProjectContextRequest {
|
|
2
|
+
/** Where the `.env` is written. Must be outside `workTree`. */
|
|
3
|
+
dir: string;
|
|
4
|
+
/** The git clone the agent edits. */
|
|
5
|
+
workTree: string;
|
|
6
|
+
databaseId: string;
|
|
7
|
+
accessToken: string;
|
|
8
|
+
/** Written as `DATABASE_NAME`, which the record tools need. */
|
|
9
|
+
databaseName?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class ProjectContextInsideWorkTreeError extends Error {
|
|
12
|
+
readonly dir: string;
|
|
13
|
+
readonly workTree: string;
|
|
14
|
+
constructor(dir: string, workTree: string);
|
|
15
|
+
}
|
|
16
|
+
/** True when `child` is `parent` or sits beneath it. */
|
|
17
|
+
export declare function isInside(parent: string, child: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Materialize a pi project context outside the work tree and return its
|
|
20
|
+
* directory, which is what the control-plane tools take as their cwd.
|
|
21
|
+
*/
|
|
22
|
+
export declare function materializeProjectContext(request: ProjectContextRequest): Promise<string>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The cwd decision, made explicit.
|
|
3
|
+
//
|
|
4
|
+
// `@agentic-kit/pi`'s control-plane tools resolve their tenant through
|
|
5
|
+
// `resolveProjectContext(cwd)`, which reads `<cwd>/.env` for `ACCESS_TOKEN` and
|
|
6
|
+
// `DATABASE_ID`. In a Job the cwd is a git clone of someone's repository, and
|
|
7
|
+
// writing platform credentials into a work tree the agent is about to `git add`
|
|
8
|
+
// is a credential leak one `git add -A` away — so the coding lane never
|
|
9
|
+
// establishes project context in the clone.
|
|
10
|
+
//
|
|
11
|
+
// It is still legitimate to want both lanes in one run (edit the repo *and*
|
|
12
|
+
// change the schema), so the context is materialized in a directory beside the
|
|
13
|
+
// clone and handed to the tools as their cwd. This module owns that directory
|
|
14
|
+
// and refuses to create it anywhere inside the work tree.
|
|
15
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
16
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
17
|
+
};
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.ProjectContextInsideWorkTreeError = void 0;
|
|
20
|
+
exports.isInside = isInside;
|
|
21
|
+
exports.materializeProjectContext = materializeProjectContext;
|
|
22
|
+
const promises_1 = require("node:fs/promises");
|
|
23
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
24
|
+
class ProjectContextInsideWorkTreeError extends Error {
|
|
25
|
+
dir;
|
|
26
|
+
workTree;
|
|
27
|
+
constructor(dir, workTree) {
|
|
28
|
+
super(`refusing to write project credentials to ${dir}: it is inside the work tree ${workTree}, where a commit would publish them`);
|
|
29
|
+
this.dir = dir;
|
|
30
|
+
this.workTree = workTree;
|
|
31
|
+
this.name = 'ProjectContextInsideWorkTreeError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
exports.ProjectContextInsideWorkTreeError = ProjectContextInsideWorkTreeError;
|
|
35
|
+
/** True when `child` is `parent` or sits beneath it. */
|
|
36
|
+
function isInside(parent, child) {
|
|
37
|
+
const rel = node_path_1.default.relative(node_path_1.default.resolve(parent), node_path_1.default.resolve(child));
|
|
38
|
+
return rel === '' || (!rel.startsWith('..') && !node_path_1.default.isAbsolute(rel));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Materialize a pi project context outside the work tree and return its
|
|
42
|
+
* directory, which is what the control-plane tools take as their cwd.
|
|
43
|
+
*/
|
|
44
|
+
async function materializeProjectContext(request) {
|
|
45
|
+
if (isInside(request.workTree, request.dir)) {
|
|
46
|
+
throw new ProjectContextInsideWorkTreeError(request.dir, request.workTree);
|
|
47
|
+
}
|
|
48
|
+
if (!request.databaseId || !request.accessToken) {
|
|
49
|
+
throw new Error('a project context needs both DATABASE_ID and ACCESS_TOKEN');
|
|
50
|
+
}
|
|
51
|
+
await (0, promises_1.mkdir)(request.dir, { recursive: true, mode: 0o700 });
|
|
52
|
+
const lines = [
|
|
53
|
+
`DATABASE_ID=${request.databaseId}`,
|
|
54
|
+
`ACCESS_TOKEN=${request.accessToken}`,
|
|
55
|
+
...(request.databaseName ? [`DATABASE_NAME=${request.databaseName}`] : [])
|
|
56
|
+
];
|
|
57
|
+
await (0, promises_1.writeFile)(node_path_1.default.join(request.dir, '.env'), `${lines.join('\n')}\n`, { mode: 0o600 });
|
|
58
|
+
return request.dir;
|
|
59
|
+
}
|
package/tools.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AgentTool } from '@agentic-kit/agent';
|
|
2
|
+
import type { ConfirmGate, ConfirmGateDeps, GateHost } from '@agentic-kit/harness';
|
|
3
|
+
export interface GatedToolsetOptions {
|
|
4
|
+
tools: AgentTool[];
|
|
5
|
+
host: GateHost;
|
|
6
|
+
/** The directory the gate's project checks are about. */
|
|
7
|
+
cwd: string;
|
|
8
|
+
/** Supply a gate to test against; defaults to the harness's. */
|
|
9
|
+
gate?: ConfirmGate;
|
|
10
|
+
/**
|
|
11
|
+
* The harness gate's project questions. A coding lane whose cwd is a git
|
|
12
|
+
* clone answers "not a Constructive project" — see `project-context.ts`.
|
|
13
|
+
*/
|
|
14
|
+
deps?: ConfirmGateDeps;
|
|
15
|
+
}
|
|
16
|
+
export interface GatedToolset {
|
|
17
|
+
tools: AgentTool[];
|
|
18
|
+
/** Call before each run: clears the decline memory. */
|
|
19
|
+
onAgentStart: () => void;
|
|
20
|
+
}
|
|
21
|
+
/** The gate's deps for a workspace that is a git clone, not a Constructive project. */
|
|
22
|
+
export declare const CLONE_GATE_DEPS: ConfirmGateDeps;
|
|
23
|
+
export declare function createGatedToolset(options: GatedToolsetOptions): GatedToolset;
|
package/tools.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Putting the harness gate in front of the agent's tools.
|
|
3
|
+
//
|
|
4
|
+
// `createConfirmGate` is the harness's, unchanged: it decides which tools are
|
|
5
|
+
// gated (`MUTATING_DB_TOOLS`), writes the prompt, and remembers declines so a
|
|
6
|
+
// retry is skipped rather than re-asked. All this adds is the seam — the gate
|
|
7
|
+
// runs inside `execute`, and a blocked call returns the gate's reason to the
|
|
8
|
+
// model as an ordinary tool result, which is how the model learns it was
|
|
9
|
+
// declined.
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.CLONE_GATE_DEPS = void 0;
|
|
12
|
+
exports.createGatedToolset = createGatedToolset;
|
|
13
|
+
const harness_1 = require("@agentic-kit/harness");
|
|
14
|
+
/** The gate's deps for a workspace that is a git clone, not a Constructive project. */
|
|
15
|
+
exports.CLONE_GATE_DEPS = {
|
|
16
|
+
isProjectRunnable: async () => false,
|
|
17
|
+
hasDataToken: async () => false,
|
|
18
|
+
resolveTemplatePreview: async () => undefined
|
|
19
|
+
};
|
|
20
|
+
function createGatedToolset(options) {
|
|
21
|
+
const gate = options.gate ?? (0, harness_1.createConfirmGate)(options.deps ?? exports.CLONE_GATE_DEPS);
|
|
22
|
+
const { host, cwd } = options;
|
|
23
|
+
const tools = options.tools.map((tool) => ({
|
|
24
|
+
...tool,
|
|
25
|
+
execute: async (toolCallId, params, decision, signal, onUpdate) => {
|
|
26
|
+
const blocked = await gate.onToolCall({ toolName: tool.name, toolCallId, input: params }, host, cwd);
|
|
27
|
+
if (blocked)
|
|
28
|
+
return blockedResult(blocked.reason);
|
|
29
|
+
return tool.execute(toolCallId, params, decision, signal, onUpdate);
|
|
30
|
+
}
|
|
31
|
+
}));
|
|
32
|
+
return { tools, onAgentStart: gate.onAgentStart };
|
|
33
|
+
}
|
|
34
|
+
function blockedResult(reason) {
|
|
35
|
+
return { content: [{ type: 'text', text: reason }] };
|
|
36
|
+
}
|