pi-pstack-adapter 0.3.0 → 0.5.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/CHANGELOG.md +15 -0
- package/README.md +38 -6
- package/VERSION +1 -1
- package/adapter-skills/setup.md +48 -6
- package/docs/compatibility.md +64 -17
- package/index.ts +61 -24
- package/package.json +1 -1
- package/src/activation.ts +41 -24
- package/src/agents.ts +36 -8
- package/src/capabilities.ts +56 -31
- package/src/certified.ts +3 -3
- package/src/config.ts +189 -8
- package/src/delegation.ts +143 -0
- package/src/echo.ts +9 -0
- package/src/generate.ts +212 -20
- package/src/namespace.ts +3 -1
- package/src/registry.ts +5 -2
- package/src/status.ts +51 -8
- package/src/types.ts +9 -0
- package/src/upstream.ts +19 -5
package/src/activation.ts
CHANGED
|
@@ -2,23 +2,26 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { posix, win32 } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_DELEGATION,
|
|
7
|
+
type DelegationHost,
|
|
8
|
+
type ToolPresence,
|
|
9
|
+
callableTool,
|
|
10
|
+
delegationHost,
|
|
11
|
+
resolveDelegation,
|
|
12
|
+
} from "./delegation.ts";
|
|
13
|
+
import type { DelegationProvider, GeneratedSkill } from "./types.ts";
|
|
11
14
|
|
|
12
15
|
/** The slice of Pi's tool registry that activation reads and writes. */
|
|
13
16
|
export interface ToolLoadout {
|
|
14
|
-
getAllTools(): readonly
|
|
17
|
+
getAllTools(): readonly ToolPresence[];
|
|
15
18
|
getActiveTools(): string[];
|
|
16
19
|
setActiveTools(toolNames: string[]): void;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
export type ActivationResult =
|
|
20
|
-
| { readonly kind: "already-active" }
|
|
21
|
-
| { readonly kind: "activated" }
|
|
23
|
+
| { readonly kind: "already-active"; readonly backend: DelegationProvider }
|
|
24
|
+
| { readonly kind: "activated"; readonly backend: DelegationProvider }
|
|
22
25
|
| { readonly kind: "unavailable" };
|
|
23
26
|
|
|
24
27
|
export function needsDelegation(skill: GeneratedSkill): boolean {
|
|
@@ -54,37 +57,51 @@ export function skillFromReadPath(
|
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/**
|
|
57
|
-
* Adds
|
|
58
|
-
* delegation skill never depends on the model
|
|
59
|
-
*
|
|
60
|
+
* Adds the delegation backend that capability detection resolves to the active tools, with its
|
|
61
|
+
* companion tools, so a delegation skill never depends on the model loading it first. For
|
|
62
|
+
* pi-subagents that is the selection `subagents_enable` makes; T3's `delegate_task` may be
|
|
63
|
+
* deferred behind `tool_search`. Pi applies the new loadout on the next model request.
|
|
60
64
|
*/
|
|
61
|
-
export function activateDelegation(
|
|
62
|
-
|
|
65
|
+
export function activateDelegation(
|
|
66
|
+
loadout: ToolLoadout,
|
|
67
|
+
preferred: DelegationProvider = DEFAULT_DELEGATION,
|
|
68
|
+
host: DelegationHost = delegationHost(),
|
|
69
|
+
): ActivationResult {
|
|
70
|
+
const registered = loadout.getAllTools();
|
|
71
|
+
const resolution = resolveDelegation(registered, preferred, host);
|
|
72
|
+
if (resolution.kind === "unavailable") return { kind: "unavailable" };
|
|
73
|
+
const { backend, companions } = resolution;
|
|
74
|
+
const wanted = [backend.tool, ...companions];
|
|
63
75
|
const active = loadout.getActiveTools();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
76
|
+
const missing = wanted.filter((name) => !active.includes(name));
|
|
77
|
+
if (missing.length === 0) return { kind: "already-active", backend: backend.id };
|
|
78
|
+
loadout.setActiveTools([...active, ...missing]);
|
|
79
|
+
return { kind: "activated", backend: backend.id };
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
export interface ActivationSources {
|
|
70
83
|
/** Generated skills, or none while the adapter has no upstream checkout. */
|
|
71
84
|
readonly skills: () => readonly GeneratedSkill[];
|
|
72
85
|
readonly modeActive: () => boolean;
|
|
86
|
+
/** The configured backend preference, read at activation time. */
|
|
87
|
+
readonly preferred: () => DelegationProvider;
|
|
88
|
+
/** Defaults to the running process's T3 Code bridge, if any. */
|
|
89
|
+
readonly host?: DelegationHost;
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
type ActivationHost = Pick<ExtensionAPI, "on" | "getAllTools" | "getActiveTools" | "setActiveTools">;
|
|
76
93
|
|
|
77
94
|
/**
|
|
78
|
-
* A delegation skill must not depend on the model remembering to
|
|
79
|
-
* so a slash command, a model-initiated SKILL.md read, and Poteto Mode each activate
|
|
80
|
-
* `subagent` in code.
|
|
95
|
+
* A delegation skill must not depend on the model remembering to load its delegation tool,
|
|
96
|
+
* so a slash command, a model-initiated SKILL.md read, and Poteto Mode each activate it in code.
|
|
81
97
|
*/
|
|
82
98
|
export function registerDelegationActivation(pi: ActivationHost, sources: ActivationSources): void {
|
|
99
|
+
const activate = () => activateDelegation(pi, sources.preferred(), sources.host ?? delegationHost());
|
|
83
100
|
// `input` runs before Pi copies the tool list for the prompt, so the first model request
|
|
84
|
-
// already carries
|
|
101
|
+
// already carries the tool. It also covers messages queued while the agent is streaming.
|
|
85
102
|
pi.on("input", async (event) => {
|
|
86
103
|
const skill = skillFromPrompt(event.text, sources.skills());
|
|
87
|
-
if (sources.modeActive() || (skill && needsDelegation(skill)))
|
|
104
|
+
if (sources.modeActive() || (skill && needsDelegation(skill))) activate();
|
|
88
105
|
});
|
|
89
106
|
|
|
90
107
|
pi.on("tool_call", async (event, ctx) => {
|
|
@@ -92,12 +109,12 @@ export function registerDelegationActivation(pi: ActivationHost, sources: Activa
|
|
|
92
109
|
const path = (event.input as { path?: unknown }).path;
|
|
93
110
|
if (typeof path !== "string") return;
|
|
94
111
|
const skill = skillFromReadPath(path, ctx.cwd, sources.skills());
|
|
95
|
-
if (skill && needsDelegation(skill))
|
|
112
|
+
if (skill && needsDelegation(skill)) activate();
|
|
96
113
|
});
|
|
97
114
|
|
|
98
115
|
// Backstop for prompts that reach the agent without an `input` event.
|
|
99
116
|
pi.on("before_agent_start", async () => {
|
|
100
|
-
if (sources.modeActive())
|
|
117
|
+
if (sources.modeActive()) activate();
|
|
101
118
|
});
|
|
102
119
|
}
|
|
103
120
|
|
package/src/agents.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { T3_DELEGATE_TOOL } from "./delegation.ts";
|
|
3
|
+
import type { DelegationProvider, Diagnostic, GeneratedAgent } from "./types.ts";
|
|
2
4
|
|
|
3
5
|
export const RUNTIME_AGENT_REGISTER_EVENT = "pi-subagents:runtime-agent-register:v1";
|
|
4
6
|
|
|
@@ -19,6 +21,9 @@ export interface EventBus {
|
|
|
19
21
|
|
|
20
22
|
export interface AgentRegistrationResult {
|
|
21
23
|
readonly registrations: readonly RuntimeRegistration[];
|
|
24
|
+
/** Agents no installed extension accepted as named agents. */
|
|
25
|
+
readonly unregistered: readonly GeneratedAgent[];
|
|
26
|
+
/** Registrations a provider refused. */
|
|
22
27
|
readonly diagnostics: readonly Diagnostic[];
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -32,6 +37,7 @@ export function registerAgents(
|
|
|
32
37
|
namespace: string,
|
|
33
38
|
): AgentRegistrationResult {
|
|
34
39
|
const registrations: RuntimeRegistration[] = [];
|
|
40
|
+
const unregistered: GeneratedAgent[] = [];
|
|
35
41
|
const diagnostics: Diagnostic[] = [];
|
|
36
42
|
|
|
37
43
|
for (const agent of agents) {
|
|
@@ -45,12 +51,7 @@ export function registerAgents(
|
|
|
45
51
|
};
|
|
46
52
|
bus.emit(RUNTIME_AGENT_REGISTER_EVENT, request);
|
|
47
53
|
if (!request.result) {
|
|
48
|
-
|
|
49
|
-
level: "warning",
|
|
50
|
-
resource: `agent:${agent.upstreamName}`,
|
|
51
|
-
message: `No installed delegation provider accepted \`${agent.generatedName}\`, so pstack workflows cannot delegate to it.`,
|
|
52
|
-
action: `Install a provider that owns ${RUNTIME_AGENT_REGISTER_EVENT}, for example \`pi install npm:pi-subagents\`.`,
|
|
53
|
-
});
|
|
54
|
+
unregistered.push(agent);
|
|
54
55
|
continue;
|
|
55
56
|
}
|
|
56
57
|
if (!request.result.ok) {
|
|
@@ -65,5 +66,32 @@ export function registerAgents(
|
|
|
65
66
|
registrations.push(request.result.registration);
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
return { registrations, diagnostics };
|
|
69
|
+
return { registrations, unregistered, diagnostics };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* T3 Code children have no named agents and load an agent from its prompt file, so a missing
|
|
74
|
+
* pi-subagents registration only blocks delegation when T3 is not the resolved backend.
|
|
75
|
+
*/
|
|
76
|
+
export function unregisteredAgentDiagnostics(
|
|
77
|
+
unregistered: readonly GeneratedAgent[],
|
|
78
|
+
backend: DelegationProvider | undefined,
|
|
79
|
+
promptsDir: string,
|
|
80
|
+
): Diagnostic[] {
|
|
81
|
+
return unregistered.map((agent) => {
|
|
82
|
+
const promptPath = join(promptsDir, `${agent.generatedName}.md`);
|
|
83
|
+
if (backend === "t3") {
|
|
84
|
+
return {
|
|
85
|
+
level: "info",
|
|
86
|
+
resource: `agent:${agent.upstreamName}`,
|
|
87
|
+
message: `\`${agent.generatedName}\` is not a named agent here. T3 Code children run it by reading ${promptPath} as their system prompt.`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
level: "warning",
|
|
92
|
+
resource: `agent:${agent.upstreamName}`,
|
|
93
|
+
message: `No installed delegation provider accepted \`${agent.generatedName}\` as a named agent, so pstack workflows cannot delegate to it through \`subagent\`.`,
|
|
94
|
+
action: `Install a provider that owns ${RUNTIME_AGENT_REGISTER_EVENT}, for example \`pi install npm:pi-subagents\`, or run Pi inside T3 Code, whose \`${T3_DELEGATE_TOOL}\` loads the agent from ${promptPath}.`,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
69
97
|
}
|
package/src/capabilities.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_DELEGATION,
|
|
3
|
+
type DelegationHost,
|
|
4
|
+
callableTool,
|
|
5
|
+
delegationHost,
|
|
6
|
+
parameterNames,
|
|
7
|
+
resolveDelegation,
|
|
8
|
+
} from "./delegation.ts";
|
|
9
|
+
import type { CapabilityId, CapabilityStatus, DelegationProvider } from "./types.ts";
|
|
2
10
|
|
|
3
11
|
export interface ToolDescriptor {
|
|
4
12
|
readonly name: string;
|
|
13
|
+
readonly exposure?: string;
|
|
5
14
|
readonly parameters?: unknown;
|
|
6
|
-
readonly sourceInfo?: { readonly source?: string };
|
|
15
|
+
readonly sourceInfo?: { readonly source?: string; readonly path?: string };
|
|
7
16
|
}
|
|
8
17
|
|
|
9
18
|
interface CapabilityProbe {
|
|
@@ -14,13 +23,10 @@ interface CapabilityProbe {
|
|
|
14
23
|
readonly recommendation: string;
|
|
15
24
|
}
|
|
16
25
|
|
|
26
|
+
const DELEGATION_RECOMMENDATION =
|
|
27
|
+
"Run Pi inside T3 Code, whose `delegate_task` tool starts child agents, or install a delegation provider with named background agents and model selection, for example `pi install npm:pi-subagents`.";
|
|
28
|
+
|
|
17
29
|
const PROBES: readonly CapabilityProbe[] = [
|
|
18
|
-
{
|
|
19
|
-
id: "delegation",
|
|
20
|
-
toolName: "subagent",
|
|
21
|
-
requiredParameters: ["agent", "task", "async", "model"],
|
|
22
|
-
recommendation: "Install a delegation provider that exposes named background agents with model selection, for example `pi install npm:pi-subagents`.",
|
|
23
|
-
},
|
|
24
30
|
{
|
|
25
31
|
id: "structured-question",
|
|
26
32
|
toolName: "ask_user",
|
|
@@ -29,31 +35,50 @@ const PROBES: readonly CapabilityProbe[] = [
|
|
|
29
35
|
},
|
|
30
36
|
];
|
|
31
37
|
|
|
32
|
-
export function detectCapabilities(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const properties = parameterNames(tool.parameters);
|
|
39
|
-
const missing = probe.requiredParameters.filter((name) => !properties.has(name));
|
|
40
|
-
if (missing.length > 0) {
|
|
41
|
-
return {
|
|
42
|
-
id: probe.id,
|
|
43
|
-
available: false,
|
|
44
|
-
provider: tool.sourceInfo?.source,
|
|
45
|
-
recommendation: `The \`${probe.toolName}\` tool is present but does not accept ${missing.join(", ")}. ${probe.recommendation}`,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
return { id: probe.id, available: true, provider: tool.sourceInfo?.source, recommendation: probe.recommendation };
|
|
49
|
-
});
|
|
38
|
+
export function detectCapabilities(
|
|
39
|
+
tools: readonly ToolDescriptor[],
|
|
40
|
+
preferred: DelegationProvider = DEFAULT_DELEGATION,
|
|
41
|
+
host: DelegationHost = delegationHost(),
|
|
42
|
+
): CapabilityStatus[] {
|
|
43
|
+
return [detectDelegation(tools, preferred, host), ...PROBES.map((probe) => detectTool(tools, probe))];
|
|
50
44
|
}
|
|
51
45
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
46
|
+
/** The first usable backend in preference order satisfies `delegation`. */
|
|
47
|
+
function detectDelegation(
|
|
48
|
+
tools: readonly ToolDescriptor[],
|
|
49
|
+
preferred: DelegationProvider,
|
|
50
|
+
host: DelegationHost,
|
|
51
|
+
): CapabilityStatus {
|
|
52
|
+
const resolution = resolveDelegation(tools, preferred, host);
|
|
53
|
+
if (resolution.kind === "unavailable") {
|
|
54
|
+
return { id: "delegation", available: false, recommendation: [...resolution.problems, DELEGATION_RECOMMENDATION].join(" ") };
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
id: "delegation",
|
|
58
|
+
available: true,
|
|
59
|
+
provider: resolution.backend.id === "t3" ? resolution.backend.label : resolution.tool.sourceInfo?.source ?? resolution.backend.label,
|
|
60
|
+
backend: resolution.backend.id,
|
|
61
|
+
tools: [resolution.backend.tool, ...resolution.companions],
|
|
62
|
+
recommendation: DELEGATION_RECOMMENDATION,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function detectTool(tools: readonly ToolDescriptor[], probe: CapabilityProbe): CapabilityStatus {
|
|
67
|
+
const tool = callableTool(tools, probe.toolName);
|
|
68
|
+
if (!tool) {
|
|
69
|
+
return { id: probe.id, available: false, recommendation: probe.recommendation };
|
|
70
|
+
}
|
|
71
|
+
const properties = parameterNames(tool.parameters);
|
|
72
|
+
const missing = probe.requiredParameters.filter((name) => !properties.has(name));
|
|
73
|
+
if (missing.length > 0) {
|
|
74
|
+
return {
|
|
75
|
+
id: probe.id,
|
|
76
|
+
available: false,
|
|
77
|
+
provider: tool.sourceInfo?.source,
|
|
78
|
+
recommendation: `The \`${probe.toolName}\` tool is present but does not accept ${missing.join(", ")}. ${probe.recommendation}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return { id: probe.id, available: true, provider: tool.sourceInfo?.source, recommendation: probe.recommendation };
|
|
57
82
|
}
|
|
58
83
|
|
|
59
84
|
export interface PrerequisiteReport {
|
package/src/certified.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
export const CERTIFIED = {
|
|
6
6
|
repo: "https://github.com/cursor/plugins",
|
|
7
7
|
ref: "main",
|
|
8
|
-
commit: "
|
|
8
|
+
commit: "dbf49813a39c1a5389c5e28cb8e4c1c2379e2454",
|
|
9
9
|
pluginPath: "pstack",
|
|
10
|
-
pstackVersion: "0.15.
|
|
11
|
-
skillCount:
|
|
10
|
+
pstackVersion: "0.15.23",
|
|
11
|
+
skillCount: 53,
|
|
12
12
|
agentCount: 2,
|
|
13
13
|
} as const;
|
package/src/config.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
3
|
import { CERTIFIED } from "./certified.ts";
|
|
4
|
+
import { safeEcho } from "./echo.ts";
|
|
4
5
|
import { DEFAULT_NAMESPACE, validateNamespace } from "./namespace.ts";
|
|
5
6
|
import { expandHome } from "./paths.ts";
|
|
6
|
-
import
|
|
7
|
+
import { DEFAULT_DELEGATION, DELEGATION_PROVIDERS, isDelegationProvider } from "./delegation.ts";
|
|
8
|
+
import type { DelegationProvider, SourceSelection, Trust } from "./types.ts";
|
|
7
9
|
|
|
8
10
|
export interface AdapterConfig {
|
|
9
11
|
readonly namespace: string;
|
|
@@ -17,6 +19,8 @@ export interface AdapterConfig {
|
|
|
17
19
|
readonly allowBootstrap: boolean;
|
|
18
20
|
readonly cacheDir?: string;
|
|
19
21
|
readonly models: Readonly<Record<string, string>>;
|
|
22
|
+
/** The delegation backend tried first. The other one is the fallback. */
|
|
23
|
+
readonly delegation: DelegationProvider;
|
|
20
24
|
readonly sources: readonly string[];
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -30,6 +34,7 @@ export interface ConfigFile {
|
|
|
30
34
|
allowBootstrap?: boolean;
|
|
31
35
|
cacheDir?: string;
|
|
32
36
|
models?: Record<string, string>;
|
|
37
|
+
delegation?: string;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
export interface ConfigLayer {
|
|
@@ -49,6 +54,7 @@ const ENV = {
|
|
|
49
54
|
allowBootstrap: "PISTACK_ALLOW_BOOTSTRAP",
|
|
50
55
|
cacheDir: "PISTACK_CACHE_DIR",
|
|
51
56
|
models: "PISTACK_MODELS",
|
|
57
|
+
delegation: "PISTACK_DELEGATION",
|
|
52
58
|
} as const;
|
|
53
59
|
|
|
54
60
|
export function readConfigFile(path: string): ConfigLayer | undefined {
|
|
@@ -61,8 +67,9 @@ export function readConfigFile(path: string): ConfigLayer | undefined {
|
|
|
61
67
|
let parsed: unknown;
|
|
62
68
|
try {
|
|
63
69
|
parsed = JSON.parse(raw);
|
|
64
|
-
} catch
|
|
65
|
-
|
|
70
|
+
} catch {
|
|
71
|
+
// The parser's message quotes raw file text, which can reach the agent. Say only where to look.
|
|
72
|
+
throw new Error(`${path} is not valid JSON. Fix the syntax in that file and restart Pi.`);
|
|
66
73
|
}
|
|
67
74
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
68
75
|
throw new Error(`${path} must contain a JSON object.`);
|
|
@@ -82,7 +89,139 @@ export function updateConfigFile(path: string, patch: ConfigFile): ConfigFile {
|
|
|
82
89
|
return next;
|
|
83
90
|
}
|
|
84
91
|
|
|
85
|
-
/** `
|
|
92
|
+
/** Thinking levels Pi accepts as a `:level` suffix on a model id. */
|
|
93
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
94
|
+
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
95
|
+
|
|
96
|
+
export function isThinkingLevel(value: string): value is ThinkingLevel {
|
|
97
|
+
return (THINKING_LEVELS as readonly string[]).includes(value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ModelSpec {
|
|
101
|
+
readonly model: string;
|
|
102
|
+
readonly thinking?: ThinkingLevel;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Splits `provider/model[:thinking]`. Only a known level is a suffix, so a model id that
|
|
107
|
+
* itself contains a colon, such as `ollama/qwen:7b`, stays whole. Pi parses ids the same way.
|
|
108
|
+
*/
|
|
109
|
+
export function splitModelSpec(spec: string): ModelSpec {
|
|
110
|
+
const separator = spec.lastIndexOf(":");
|
|
111
|
+
if (separator > 0) {
|
|
112
|
+
const suffix = spec.slice(separator + 1).toLowerCase();
|
|
113
|
+
if (isThinkingLevel(suffix)) return { model: spec.slice(0, separator), thinking: suffix };
|
|
114
|
+
}
|
|
115
|
+
return { model: spec };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function joinModelSpec(spec: ModelSpec): string {
|
|
119
|
+
return spec.thinking === undefined ? spec.model : `${spec.model}:${spec.thinking}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface AvailableModel {
|
|
123
|
+
readonly id: string;
|
|
124
|
+
readonly reasoning?: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type ModelAssignment =
|
|
128
|
+
| { readonly kind: "inherit" }
|
|
129
|
+
| { readonly kind: "assign"; readonly spec: ModelSpec }
|
|
130
|
+
| { readonly kind: "rejected"; readonly message: string };
|
|
131
|
+
|
|
132
|
+
/** Validates a `set_model` request. `available` holds `provider/id` entries from the registry. */
|
|
133
|
+
export function planModelAssignment(
|
|
134
|
+
model: string,
|
|
135
|
+
thinking: string | undefined,
|
|
136
|
+
available: readonly AvailableModel[],
|
|
137
|
+
): ModelAssignment {
|
|
138
|
+
if (model.trim().toLowerCase() === "inherit") {
|
|
139
|
+
if (thinking?.trim()) {
|
|
140
|
+
return {
|
|
141
|
+
kind: "rejected",
|
|
142
|
+
message:
|
|
143
|
+
"A role that inherits the parent session has no thinking level of its own. Pass a `provider/model` with `thinking`, or drop `thinking`.",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return { kind: "inherit" };
|
|
147
|
+
}
|
|
148
|
+
const whole = model.trim();
|
|
149
|
+
// A registry id that itself ends in a level word, such as `x/model:low`, wins over a split.
|
|
150
|
+
const exact = available.some((candidate) => candidate.id === whole);
|
|
151
|
+
const spec = exact ? { model: whole } : splitModelSpec(whole);
|
|
152
|
+
const requested = thinking?.trim().toLowerCase() || undefined;
|
|
153
|
+
if (requested !== undefined && !isThinkingLevel(requested)) {
|
|
154
|
+
return {
|
|
155
|
+
kind: "rejected",
|
|
156
|
+
message: `Unknown thinking level \`${safeEcho(thinking).replaceAll('"', "")}\`. Use one of ${THINKING_LEVELS.map((level) => `\`${level}\``).join(", ")}.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (requested !== undefined && spec.thinking !== undefined && spec.thinking !== requested) {
|
|
160
|
+
return {
|
|
161
|
+
kind: "rejected",
|
|
162
|
+
message: `\`model\` names thinking \`${spec.thinking}\` but \`thinking\` says \`${requested}\`. Pass one of them.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const level = requested !== undefined && isThinkingLevel(requested) ? requested : spec.thinking;
|
|
166
|
+
if (!isValidModelSpec(spec.model)) return { kind: "rejected", message: invalidModelMessage(spec.model) };
|
|
167
|
+
if (exact && requested === undefined && splitModelSpec(whole).thinking !== undefined) {
|
|
168
|
+
// Stored whole, `x/model:low` would read back as model `x/model` at thinking `low`.
|
|
169
|
+
return {
|
|
170
|
+
kind: "rejected",
|
|
171
|
+
message: `Model \`${whole}\` ends in a thinking-level word, so a plain assignment would be misread. Pass \`thinking\` explicitly, for example \`high\`.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const known = available.find((candidate) => candidate.id === spec.model);
|
|
175
|
+
if (!known) {
|
|
176
|
+
return {
|
|
177
|
+
kind: "rejected",
|
|
178
|
+
message: `No model \`${spec.model}\` is available in this Pi installation. Call this tool with action "models" for real ids, or pass "inherit".`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (level !== undefined && level !== "off" && known.reasoning === false) {
|
|
182
|
+
return {
|
|
183
|
+
kind: "rejected",
|
|
184
|
+
message: `Model \`${spec.model}\` does not support thinking. Drop \`thinking\`, or pick a reasoning model.`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const assigned: ModelSpec = { model: spec.model, thinking: level };
|
|
188
|
+
if (!isValidModelSpec(joinModelSpec(assigned))) return { kind: "rejected", message: invalidModelMessage(joinModelSpec(assigned)) };
|
|
189
|
+
return { kind: "assign", spec: assigned };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Role names reach generated markdown and status tables, so they stay plain kebab-case. */
|
|
193
|
+
const ROLE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
194
|
+
/**
|
|
195
|
+
* A model spec is also rendered into markdown the agent reads, so it is printable ASCII with the
|
|
196
|
+
* markup characters removed. An allow-list of ASCII keeps invisible text (tag characters, zero-width
|
|
197
|
+
* and bidi controls) out; catalogue ids such as OpenRouter's `~anthropic/...` aliases are ASCII.
|
|
198
|
+
* A trailing colon is never a valid id or level.
|
|
199
|
+
*/
|
|
200
|
+
const PRINTABLE_ASCII = /^[\x21-\x7e]+$/;
|
|
201
|
+
const MARKUP_CHARACTERS = /[`|<>[\]()\\*]/;
|
|
202
|
+
|
|
203
|
+
const MAX_MODEL_SPEC_LENGTH = 128;
|
|
204
|
+
|
|
205
|
+
export function isValidModelSpec(model: string): boolean {
|
|
206
|
+
const plain = (value: string) => PRINTABLE_ASCII.test(value) && !MARKUP_CHARACTERS.test(value) && !value.endsWith(":");
|
|
207
|
+
if (model.length > MAX_MODEL_SPEC_LENGTH || !plain(model)) return false;
|
|
208
|
+
// `x/model::high` splits to `x/model:`, which is no model id.
|
|
209
|
+
return plain(splitModelSpec(model).model);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function isValidRole(role: string): boolean {
|
|
213
|
+
return ROLE_PATTERN.test(role);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function invalidRoleMessage(role: string): string {
|
|
217
|
+
return `Invalid role name ${safeEcho(role)}. Use lowercase letters, digits, and hyphens, starting with a letter, for example "bug-fix".`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function invalidModelMessage(model: unknown): string {
|
|
221
|
+
return `Invalid model value ${typeof model === "string" ? `(${model.length} characters)` : safeEcho(model)}. Use provider/model or provider/model:thinking with no spaces or markup characters, at most 128 characters.`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `role=provider/model[:thinking],role2=provider/model2` */
|
|
86
225
|
export function parseModelRoles(value: string): Record<string, string> {
|
|
87
226
|
const roles: Record<string, string> = {};
|
|
88
227
|
for (const pair of value.split(",")) {
|
|
@@ -90,9 +229,15 @@ export function parseModelRoles(value: string): Record<string, string> {
|
|
|
90
229
|
if (trimmed === "") continue;
|
|
91
230
|
const separator = trimmed.indexOf("=");
|
|
92
231
|
if (separator <= 0) {
|
|
93
|
-
throw new Error(`Invalid model role assignment ${
|
|
232
|
+
throw new Error(`Invalid model role assignment ${safeEcho(trimmed)}. Use role=provider/model or role=provider/model:thinking.`);
|
|
94
233
|
}
|
|
95
|
-
|
|
234
|
+
const role = trimmed.slice(0, separator).trim();
|
|
235
|
+
const model = trimmed.slice(separator + 1).trim();
|
|
236
|
+
if (!isValidRole(role)) throw new Error(invalidRoleMessage(role));
|
|
237
|
+
// An empty value is an unset variable that expanded to nothing, not a model.
|
|
238
|
+
if (model === "") continue;
|
|
239
|
+
if (!isValidModelSpec(model)) throw new Error(invalidModelMessage(model));
|
|
240
|
+
roles[role] = model;
|
|
96
241
|
}
|
|
97
242
|
return roles;
|
|
98
243
|
}
|
|
@@ -106,7 +251,14 @@ export function environmentLayer(env: NodeJS.ProcessEnv): ConfigLayer {
|
|
|
106
251
|
if (env[ENV.pluginPath]) values.pluginPath = env[ENV.pluginPath];
|
|
107
252
|
if (env[ENV.cacheDir]) values.cacheDir = env[ENV.cacheDir];
|
|
108
253
|
if (env[ENV.allowBootstrap]) values.allowBootstrap = !NEGATIVE.has((env[ENV.allowBootstrap] as string).trim().toLowerCase());
|
|
109
|
-
if (env[ENV.models])
|
|
254
|
+
if (env[ENV.models]) {
|
|
255
|
+
try {
|
|
256
|
+
values.models = parseModelRoles(env[ENV.models] as string);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
throw new Error(`${ENV.models}: ${(error as Error).message}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (env[ENV.delegation]) values.delegation = (env[ENV.delegation] as string).trim().toLowerCase();
|
|
110
262
|
return { label: "environment", values };
|
|
111
263
|
}
|
|
112
264
|
|
|
@@ -128,7 +280,7 @@ export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
|
|
|
128
280
|
merged.ref = undefined;
|
|
129
281
|
}
|
|
130
282
|
if (key === "models") {
|
|
131
|
-
merged.models = { ...merged.models, ...(value
|
|
283
|
+
merged.models = { ...merged.models, ...validatedModels(value, layer.label) };
|
|
132
284
|
} else {
|
|
133
285
|
(merged as Record<string, unknown>)[key] = value;
|
|
134
286
|
}
|
|
@@ -151,10 +303,30 @@ export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
|
|
|
151
303
|
allowBootstrap: merged.allowBootstrap ?? false,
|
|
152
304
|
cacheDir: merged.cacheDir,
|
|
153
305
|
models: merged.models ?? {},
|
|
306
|
+
delegation: validateDelegation(merged.delegation, layers),
|
|
154
307
|
sources,
|
|
155
308
|
};
|
|
156
309
|
}
|
|
157
310
|
|
|
311
|
+
/** A bad `models` entry fails with the layer that holds it, so status can still be reached after a fix. */
|
|
312
|
+
function validatedModels(value: unknown, label: string): Record<string, string> {
|
|
313
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
314
|
+
throw new Error(`${label} sets \`models\` to something other than an object of role to provider/model.`);
|
|
315
|
+
}
|
|
316
|
+
const models: Record<string, string> = {};
|
|
317
|
+
for (const [role, model] of Object.entries(value)) {
|
|
318
|
+
if (!isValidRole(role)) {
|
|
319
|
+
throw new Error(`${label}: ${invalidRoleMessage(role)} Edit that key in \`models\` in the named file, then restart Pi.`);
|
|
320
|
+
}
|
|
321
|
+
if (model === "") continue;
|
|
322
|
+
if (typeof model !== "string" || !isValidModelSpec(model)) {
|
|
323
|
+
throw new Error(`${label}: role ${safeEcho(role)}. ${invalidModelMessage(model)}`);
|
|
324
|
+
}
|
|
325
|
+
Object.defineProperty(models, role, { value: model, enumerable: true, writable: true, configurable: true });
|
|
326
|
+
}
|
|
327
|
+
return models;
|
|
328
|
+
}
|
|
329
|
+
|
|
158
330
|
/**
|
|
159
331
|
* The certified commit is the default, not a floor. Naming a ref or a repository without a
|
|
160
332
|
* commit means the user asked for that ref, so the pin must get out of the way and trust
|
|
@@ -167,6 +339,15 @@ function resolvePin(merged: ConfigFile): string | undefined {
|
|
|
167
339
|
return CERTIFIED.commit;
|
|
168
340
|
}
|
|
169
341
|
|
|
342
|
+
function validateDelegation(value: string | undefined, layers: readonly ConfigLayer[]): DelegationProvider {
|
|
343
|
+
if (value === undefined) return DEFAULT_DELEGATION;
|
|
344
|
+
if (isDelegationProvider(value)) return value;
|
|
345
|
+
const owner = [...layers].reverse().find((layer) => layer.values.delegation === value);
|
|
346
|
+
throw new Error(
|
|
347
|
+
`${owner?.label ?? "config"} sets \`delegation\` to ${safeEcho(value)}. Use one of ${DELEGATION_PROVIDERS.map((id) => `"${id}"`).join(", ")}, or remove it to prefer "${DEFAULT_DELEGATION}".`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
170
351
|
function resolveLayerPath(value: string, layers: readonly ConfigLayer[]): string {
|
|
171
352
|
const expanded = expandHome(value);
|
|
172
353
|
if (isAbsolute(expanded)) return expanded;
|