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