@xl0/pi-lovely-agents 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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/extensions/lovely-agents/agent.ts +1374 -0
- package/extensions/lovely-agents/bash.ts +599 -0
- package/extensions/lovely-agents/child-session.ts +296 -0
- package/extensions/lovely-agents/config.ts +221 -0
- package/extensions/lovely-agents/coordinator.ts +506 -0
- package/extensions/lovely-agents/definitions.ts +380 -0
- package/extensions/lovely-agents/index.ts +400 -0
- package/extensions/lovely-agents/lifecycle.ts +251 -0
- package/extensions/lovely-agents/management.ts +638 -0
- package/extensions/lovely-agents/notifications.ts +220 -0
- package/extensions/lovely-agents/provider-limits.ts +13 -0
- package/extensions/lovely-agents/rendering.ts +90 -0
- package/extensions/lovely-agents/state.ts +1179 -0
- package/extensions/lovely-agents/task-panel.ts +192 -0
- package/extensions/lovely-agents/tools.ts +635 -0
- package/extensions/lovely-agents/updates.ts +45 -0
- package/node_modules/@xl0/pi-lovely-config/CHANGELOG.md +79 -0
- package/node_modules/@xl0/pi-lovely-config/LICENSE +21 -0
- package/node_modules/@xl0/pi-lovely-config/README.md +200 -0
- package/node_modules/@xl0/pi-lovely-config/package.json +59 -0
- package/node_modules/@xl0/pi-lovely-config/src/config.ts +399 -0
- package/node_modules/@xl0/pi-lovely-config/src/index.ts +3 -0
- package/node_modules/@xl0/pi-lovely-config/src/ui.ts +786 -0
- package/package.json +68 -0
- package/skills/agent/SKILL.md +21 -0
- package/skills/agent-creator/SKILL.md +35 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { lstat, open } from "node:fs/promises"
|
|
2
|
+
import {
|
|
3
|
+
type AgentSession,
|
|
4
|
+
type BuildSystemPromptOptions,
|
|
5
|
+
createAgentSession,
|
|
6
|
+
DefaultResourceLoader,
|
|
7
|
+
getAgentDir,
|
|
8
|
+
type LoadExtensionsResult,
|
|
9
|
+
type PromptOptions,
|
|
10
|
+
type ScopedModel,
|
|
11
|
+
SessionManager,
|
|
12
|
+
SettingsManager,
|
|
13
|
+
type Skill
|
|
14
|
+
} from "@earendil-works/pi-coding-agent"
|
|
15
|
+
import { MODEL_ALIASES, type ModelAliasChoice } from "./config.js"
|
|
16
|
+
import { getAgentCoordinator } from "./coordinator.js"
|
|
17
|
+
import type { AgentDefinition, AgentThinkingLevel } from "./definitions.js"
|
|
18
|
+
import type { TaskStoragePaths } from "./state.js"
|
|
19
|
+
|
|
20
|
+
const PROMPT_EXTENSION_PATH = "<inline:lovely-agent-prompt>"
|
|
21
|
+
const CREATION_TOOL_NAMES = new Set(["agent"])
|
|
22
|
+
|
|
23
|
+
export type ChildSessionSelection = {
|
|
24
|
+
model: ScopedModel["model"]
|
|
25
|
+
thinking: AgentThinkingLevel
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ChildToolPolicy = {
|
|
29
|
+
tools?: string[]
|
|
30
|
+
excludeTools: string[]
|
|
31
|
+
depth: number
|
|
32
|
+
allowAgents: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type CreateChildSessionOptions = {
|
|
36
|
+
cwd: string
|
|
37
|
+
paths: TaskStoragePaths
|
|
38
|
+
definition: AgentDefinition
|
|
39
|
+
selection: ChildSessionSelection
|
|
40
|
+
scopedModels: readonly ScopedModel[]
|
|
41
|
+
parentDepth: number
|
|
42
|
+
maximumDepth: number
|
|
43
|
+
allowAgents: boolean
|
|
44
|
+
projectTrusted: boolean
|
|
45
|
+
expectedSessionId?: string
|
|
46
|
+
agentDir?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ChildSessionHandle = {
|
|
50
|
+
session: AgentSession
|
|
51
|
+
extensionsResult: LoadExtensionsResult
|
|
52
|
+
depth: number
|
|
53
|
+
allowAgents: boolean
|
|
54
|
+
dispose(): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function childPromptOptions(expandPromptTemplates: boolean): PromptOptions {
|
|
58
|
+
return { expandPromptTemplates }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveChildSessionSelection(options: {
|
|
62
|
+
callModel?: string
|
|
63
|
+
callThinking?: AgentThinkingLevel
|
|
64
|
+
definition: AgentDefinition
|
|
65
|
+
configuredModels: readonly ScopedModel[]
|
|
66
|
+
aliases?: readonly ModelAliasChoice[]
|
|
67
|
+
availableModels: readonly ScopedModel["model"][]
|
|
68
|
+
parentModel: ScopedModel["model"] | undefined
|
|
69
|
+
parentThinking: AgentThinkingLevel
|
|
70
|
+
}): ChildSessionSelection {
|
|
71
|
+
let model: ScopedModel["model"] | undefined
|
|
72
|
+
const reference = options.callModel ?? options.definition.model
|
|
73
|
+
const alias = options.aliases?.find(alias => alias.name === reference)
|
|
74
|
+
if (reference && Object.hasOwn(MODEL_ALIASES, reference) && !alias) {
|
|
75
|
+
throw new Error(`Model alias "${reference}" is not configured or its model is unavailable`)
|
|
76
|
+
}
|
|
77
|
+
if (alias) {
|
|
78
|
+
model = alias.model
|
|
79
|
+
} else if (options.callModel) {
|
|
80
|
+
model = options.configuredModels.find(choice => modelId(choice.model) === options.callModel)?.model
|
|
81
|
+
if (!model) throw new Error(`Model "${options.callModel}" is not an available configured choice`)
|
|
82
|
+
} else if (options.definition.model) {
|
|
83
|
+
model = options.availableModels.find(candidate => modelId(candidate) === options.definition.model)
|
|
84
|
+
if (!model) throw new Error(`Agent Definition model "${options.definition.model}" is not authenticated`)
|
|
85
|
+
} else {
|
|
86
|
+
model = options.parentModel
|
|
87
|
+
if (!model) throw new Error("No parent model is available")
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
model,
|
|
91
|
+
thinking:
|
|
92
|
+
options.callThinking ??
|
|
93
|
+
(options.callModel ? alias?.thinkingLevel : undefined) ??
|
|
94
|
+
options.definition.thinking ??
|
|
95
|
+
alias?.thinkingLevel ??
|
|
96
|
+
options.parentThinking
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function resolveChildToolPolicy(options: {
|
|
101
|
+
definitionTools?: readonly string[]
|
|
102
|
+
parentDepth: number
|
|
103
|
+
maximumDepth: number
|
|
104
|
+
allowAgents: boolean
|
|
105
|
+
}): ChildToolPolicy {
|
|
106
|
+
if (!Number.isSafeInteger(options.parentDepth) || options.parentDepth < 0)
|
|
107
|
+
throw new Error("Parent depth must be a nonnegative safe integer")
|
|
108
|
+
if (!Number.isSafeInteger(options.maximumDepth) || options.maximumDepth < 0) {
|
|
109
|
+
throw new Error("Maximum depth must be a nonnegative safe integer")
|
|
110
|
+
}
|
|
111
|
+
const depth = options.parentDepth + 1
|
|
112
|
+
if (depth > options.maximumDepth) throw new Error(`Agent depth ${depth} exceeds configured maximum ${options.maximumDepth}`)
|
|
113
|
+
const allowAgents = options.allowAgents && depth < options.maximumDepth
|
|
114
|
+
const excludeTools = allowAgents ? [] : [...CREATION_TOOL_NAMES]
|
|
115
|
+
const tools = options.definitionTools?.filter(name => allowAgents || !CREATION_TOOL_NAMES.has(name))
|
|
116
|
+
return { ...(tools ? { tools } : {}), excludeTools, depth, allowAgents }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function createChildSession(options: CreateChildSessionOptions): Promise<ChildSessionHandle> {
|
|
120
|
+
const policy = resolveChildToolPolicy({
|
|
121
|
+
...(options.definition.tools ? { definitionTools: options.definition.tools } : {}),
|
|
122
|
+
parentDepth: options.parentDepth,
|
|
123
|
+
maximumDepth: options.maximumDepth,
|
|
124
|
+
allowAgents: options.allowAgents
|
|
125
|
+
})
|
|
126
|
+
const agentDir = options.agentDir ?? getAgentDir()
|
|
127
|
+
const settingsManager = SettingsManager.create(options.cwd, agentDir)
|
|
128
|
+
settingsManager.setProjectTrusted(options.projectTrusted)
|
|
129
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
130
|
+
cwd: options.cwd,
|
|
131
|
+
agentDir,
|
|
132
|
+
settingsManager,
|
|
133
|
+
systemPromptOverride: () => options.definition.systemPrompt,
|
|
134
|
+
...(options.definition.excludeAgentsMd ? { agentsFilesOverride: () => ({ agentsFiles: [] }) } : {}),
|
|
135
|
+
extensionFactories: [
|
|
136
|
+
{
|
|
137
|
+
name: "lovely-agent-prompt",
|
|
138
|
+
hidden: true,
|
|
139
|
+
factory(pi) {
|
|
140
|
+
pi.on("before_agent_start", event => ({
|
|
141
|
+
systemPrompt: buildDefinitionSystemPrompt(event.systemPromptOptions)
|
|
142
|
+
}))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
],
|
|
146
|
+
extensionsOverride: base => {
|
|
147
|
+
const composer = base.extensions.find(extension => extension.path === PROMPT_EXTENSION_PATH)
|
|
148
|
+
if (!composer) return base
|
|
149
|
+
return { ...base, extensions: [composer, ...base.extensions.filter(extension => extension !== composer)] }
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
await resourceLoader.reload()
|
|
153
|
+
if (!resourceLoader.getExtensions().extensions.some(extension => extension.path === PROMPT_EXTENSION_PATH)) {
|
|
154
|
+
throw new Error("Could not load the Lovely Agents prompt composer")
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await reserveSessionFile(options.paths.session)
|
|
158
|
+
const sessionManager = SessionManager.open(options.paths.session, options.paths.taskDirectory, options.cwd)
|
|
159
|
+
const result = await createAgentSession({
|
|
160
|
+
cwd: options.cwd,
|
|
161
|
+
agentDir,
|
|
162
|
+
model: options.selection.model,
|
|
163
|
+
thinkingLevel: options.selection.thinking,
|
|
164
|
+
scopedModels: [...options.scopedModels],
|
|
165
|
+
...(policy.tools ? { tools: policy.tools } : {}),
|
|
166
|
+
excludeTools: policy.excludeTools,
|
|
167
|
+
resourceLoader,
|
|
168
|
+
sessionManager,
|
|
169
|
+
settingsManager
|
|
170
|
+
})
|
|
171
|
+
if (options.expectedSessionId && result.session.sessionId !== options.expectedSessionId) {
|
|
172
|
+
result.session.dispose()
|
|
173
|
+
throw new Error(`Child session identity mismatch: expected ${options.expectedSessionId}, found ${result.session.sessionId}`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const lifetime = new AbortController()
|
|
177
|
+
const unbindContext = getAgentCoordinator().bindSessionContext(result.session.sessionId, {
|
|
178
|
+
depth: policy.depth,
|
|
179
|
+
allowAgents: policy.allowAgents,
|
|
180
|
+
disposeSignal: lifetime.signal
|
|
181
|
+
})
|
|
182
|
+
try {
|
|
183
|
+
await result.session.bindExtensions({ mode: "print" })
|
|
184
|
+
} catch (error) {
|
|
185
|
+
lifetime.abort()
|
|
186
|
+
unbindContext()
|
|
187
|
+
result.session.dispose()
|
|
188
|
+
throw error
|
|
189
|
+
}
|
|
190
|
+
let disposed = false
|
|
191
|
+
return {
|
|
192
|
+
session: result.session,
|
|
193
|
+
extensionsResult: result.extensionsResult,
|
|
194
|
+
depth: policy.depth,
|
|
195
|
+
allowAgents: policy.allowAgents,
|
|
196
|
+
dispose() {
|
|
197
|
+
if (disposed) return
|
|
198
|
+
disposed = true
|
|
199
|
+
lifetime.abort()
|
|
200
|
+
unbindContext()
|
|
201
|
+
result.session.dispose()
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function buildDefinitionSystemPrompt(options: BuildSystemPromptOptions): string {
|
|
207
|
+
const body = options.customPrompt?.trim()
|
|
208
|
+
if (!body) throw new Error("Agent Definition body is empty")
|
|
209
|
+
const tools = options.selectedTools ?? []
|
|
210
|
+
const visibleTools = tools.filter(name => options.toolSnippets?.[name])
|
|
211
|
+
const toolList = visibleTools.length > 0 ? visibleTools.map(name => `- ${name}: ${options.toolSnippets?.[name]}`).join("\n") : "(none)"
|
|
212
|
+
const guidelines: string[] = []
|
|
213
|
+
const seen = new Set<string>()
|
|
214
|
+
const addGuideline = (value: string) => {
|
|
215
|
+
const guideline = value.trim()
|
|
216
|
+
if (!guideline || seen.has(guideline)) return
|
|
217
|
+
seen.add(guideline)
|
|
218
|
+
guidelines.push(guideline)
|
|
219
|
+
}
|
|
220
|
+
const hasBash = tools.includes("bash")
|
|
221
|
+
const hasPowerShell = tools.includes("powershell")
|
|
222
|
+
if ((hasBash || hasPowerShell) && !tools.some(name => name === "grep" || name === "find" || name === "ls")) {
|
|
223
|
+
addGuideline(
|
|
224
|
+
hasBash && hasPowerShell
|
|
225
|
+
? "Use bash or PowerShell for file operations like listing, searching, and finding files"
|
|
226
|
+
: hasPowerShell
|
|
227
|
+
? "Use PowerShell for file operations like listing, searching, and finding files"
|
|
228
|
+
: "Use bash for file operations like ls, rg, find"
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
for (const guideline of options.promptGuidelines ?? []) addGuideline(guideline)
|
|
232
|
+
addGuideline("Be concise in your responses")
|
|
233
|
+
addGuideline("Show file paths clearly when working with files")
|
|
234
|
+
|
|
235
|
+
let prompt = `${body}\n\nAvailable tools:\n${toolList}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n\nGuidelines:\n${guidelines.map(value => `- ${value}`).join("\n")}`
|
|
236
|
+
if (options.appendSystemPrompt) prompt += `\n\n${options.appendSystemPrompt}`
|
|
237
|
+
if (options.contextFiles && options.contextFiles.length > 0) {
|
|
238
|
+
prompt += "\n\n<project_context>\n\nProject-specific instructions and guidelines:\n\n"
|
|
239
|
+
for (const file of options.contextFiles) {
|
|
240
|
+
prompt += `<project_instructions path="${file.path}">\n${file.content}\n</project_instructions>\n\n`
|
|
241
|
+
}
|
|
242
|
+
prompt += "</project_context>\n"
|
|
243
|
+
}
|
|
244
|
+
const skillReadTool = (["read", "bash"] as const).find(name => tools.includes(name))
|
|
245
|
+
if (skillReadTool && options.skills && options.skills.length > 0) {
|
|
246
|
+
prompt += formatSkillsForChild(options.skills, skillReadTool)
|
|
247
|
+
}
|
|
248
|
+
prompt += `\nCurrent working directory: ${options.cwd.replace(/\\/g, "/")}\n`
|
|
249
|
+
return prompt
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function modelId(model: ScopedModel["model"]): string {
|
|
253
|
+
return `${model.provider}/${model.id}`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function formatSkillsForChild(skills: Skill[], fileReadTool: "read" | "bash"): string {
|
|
257
|
+
const visible = skills.filter(skill => !skill.disableModelInvocation)
|
|
258
|
+
if (visible.length === 0) return ""
|
|
259
|
+
const lines = [
|
|
260
|
+
"",
|
|
261
|
+
"",
|
|
262
|
+
"The following skills provide specialized instructions for specific tasks.",
|
|
263
|
+
`Use the ${fileReadTool} tool to load a skill's file when the task matches its description.`,
|
|
264
|
+
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
|
|
265
|
+
"",
|
|
266
|
+
"<available_skills>"
|
|
267
|
+
]
|
|
268
|
+
for (const skill of visible) {
|
|
269
|
+
lines.push(" <skill>")
|
|
270
|
+
lines.push(` <name>${escapeXml(skill.name)}</name>`)
|
|
271
|
+
lines.push(` <description>${escapeXml(skill.description)}</description>`)
|
|
272
|
+
lines.push(` <location>${escapeXml(skill.filePath)}</location>`)
|
|
273
|
+
lines.push(" </skill>")
|
|
274
|
+
}
|
|
275
|
+
lines.push("</available_skills>")
|
|
276
|
+
return lines.join("\n")
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function escapeXml(value: string): string {
|
|
280
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'")
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function reserveSessionFile(path: string): Promise<void> {
|
|
284
|
+
try {
|
|
285
|
+
const file = await open(path, "wx", 0o600)
|
|
286
|
+
await file.close()
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (!hasCode(error, "EEXIST")) throw error
|
|
289
|
+
const stats = await lstat(path)
|
|
290
|
+
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error(`Child session path is not a regular file: ${path}`)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function hasCode(error: unknown, code: string): boolean {
|
|
295
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code
|
|
296
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { ExtensionContext, ScopedModel } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import { type ConfigFromSchema, defineScopedConfig, field, type ScopedConfig } from "@xl0/pi-lovely-config"
|
|
3
|
+
|
|
4
|
+
const NO_MODELS = "(no authenticated models)"
|
|
5
|
+
const DISABLED_MODEL = "disabled"
|
|
6
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const
|
|
7
|
+
export const MODEL_ALIASES = {
|
|
8
|
+
fast: "Cheap, low-latency model for straightforward tasks.",
|
|
9
|
+
smart: "Most capable model for difficult reasoning and complex work.",
|
|
10
|
+
workhorse: "Balanced cost and capability for routine coding and research."
|
|
11
|
+
} as const
|
|
12
|
+
export type ModelAliasName = keyof typeof MODEL_ALIASES
|
|
13
|
+
/** A user-selected preset, resolved to an authenticated model before creation. */
|
|
14
|
+
export type ModelAliasChoice = {
|
|
15
|
+
name: ModelAliasName
|
|
16
|
+
model: ScopedModel["model"]
|
|
17
|
+
thinkingLevel: NonNullable<ScopedModel["thinkingLevel"]>
|
|
18
|
+
}
|
|
19
|
+
type ModelConfigContext = Pick<ExtensionContext, "model" | "modelRegistry">
|
|
20
|
+
|
|
21
|
+
function createConfigSchema(ctx?: ModelConfigContext) {
|
|
22
|
+
const availableModels = ctx?.modelRegistry.getAvailable() ?? []
|
|
23
|
+
const modelIds = [...new Set(availableModels.map(model => `${model.provider}/${model.id}`))]
|
|
24
|
+
const modelValues = (modelIds.length > 0 ? modelIds : [NO_MODELS]) as [string, ...string[]]
|
|
25
|
+
const valueDescriptions = Object.fromEntries(availableModels.map(model => [`${model.provider}/${model.id}`, model.name || model.id]))
|
|
26
|
+
const aliasModel = (name: ModelAliasName) =>
|
|
27
|
+
field.enum([DISABLED_MODEL, ...modelIds] as [string, ...string[]], DISABLED_MODEL, {
|
|
28
|
+
label: `${name} model`,
|
|
29
|
+
description: MODEL_ALIASES[name],
|
|
30
|
+
search: true,
|
|
31
|
+
valueDescriptions: { [DISABLED_MODEL]: "Do not expose this alias", ...valueDescriptions }
|
|
32
|
+
})
|
|
33
|
+
const aliasThinking = (name: ModelAliasName, level: ModelAliasChoice["thinkingLevel"]) =>
|
|
34
|
+
field.enum(THINKING_LEVELS, level, {
|
|
35
|
+
label: `${name} thinking`,
|
|
36
|
+
description: "Preset effort; an explicit thinking argument overrides it.",
|
|
37
|
+
depth: 1,
|
|
38
|
+
visibleWhen: ctx => ctx.get(`${name}Model`) !== DISABLED_MODEL
|
|
39
|
+
})
|
|
40
|
+
return {
|
|
41
|
+
backgroundAgents: field.boolean(true, {
|
|
42
|
+
label: "Background agents",
|
|
43
|
+
description: "Allow detached agents and asynchronous Follow-ups. Off keeps agents in the foreground."
|
|
44
|
+
}),
|
|
45
|
+
backgroundBash: field.boolean(true, {
|
|
46
|
+
label: "Background Bash",
|
|
47
|
+
description: "Run Bash commands as managed background tasks."
|
|
48
|
+
}),
|
|
49
|
+
models: field.multiEnum(modelValues, [], {
|
|
50
|
+
label: "Models",
|
|
51
|
+
description: "Additional model IDs available for agent selection. Empty includes the parent. Alias targets are always included.",
|
|
52
|
+
valueDescriptions
|
|
53
|
+
}),
|
|
54
|
+
fastModel: aliasModel("fast"),
|
|
55
|
+
fastThinking: aliasThinking("fast", "low"),
|
|
56
|
+
smartModel: aliasModel("smart"),
|
|
57
|
+
smartThinking: aliasThinking("smart", "high"),
|
|
58
|
+
workhorseModel: aliasModel("workhorse"),
|
|
59
|
+
workhorseThinking: aliasThinking("workhorse", "medium"),
|
|
60
|
+
maxConcurrency: field.number(4, {
|
|
61
|
+
label: "Max concurrency",
|
|
62
|
+
description: "Maximum agent runs executing in this process.",
|
|
63
|
+
min: 1,
|
|
64
|
+
step: 1
|
|
65
|
+
}),
|
|
66
|
+
maxBashConcurrency: field.number(4, {
|
|
67
|
+
label: "Bash concurrency",
|
|
68
|
+
description: "Maximum background Bash processes, separate from agent permits.",
|
|
69
|
+
min: 1,
|
|
70
|
+
step: 1,
|
|
71
|
+
visibleWhen: ctx => ctx.get("backgroundBash") === true
|
|
72
|
+
}),
|
|
73
|
+
maxDepth: field.number(2, {
|
|
74
|
+
label: "Max depth",
|
|
75
|
+
description: "Maximum agent delegation depth. The root session is depth 0.",
|
|
76
|
+
min: 0,
|
|
77
|
+
step: 1
|
|
78
|
+
}),
|
|
79
|
+
waitMs: field.number(30_000, {
|
|
80
|
+
label: "Initial wait (ms)",
|
|
81
|
+
description: "How long agent creation waits before detaching.",
|
|
82
|
+
min: 0,
|
|
83
|
+
step: 1000,
|
|
84
|
+
visibleWhen: ctx => ctx.get("backgroundAgents") === true
|
|
85
|
+
}),
|
|
86
|
+
expandPromptTemplates: field.boolean(false, {
|
|
87
|
+
label: "Expand prompt templates",
|
|
88
|
+
description: "Interpret child skill commands, prompt templates, and extension commands."
|
|
89
|
+
})
|
|
90
|
+
} as const
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const configSchema = createConfigSchema()
|
|
94
|
+
type RawAgentsConfig = ConfigFromSchema<typeof configSchema>
|
|
95
|
+
type ConfigScope = "user" | "workspace"
|
|
96
|
+
|
|
97
|
+
export type AgentsConfig = RawAgentsConfig
|
|
98
|
+
|
|
99
|
+
export type AgentsConfigWarning = {
|
|
100
|
+
scope: ConfigScope
|
|
101
|
+
path: string
|
|
102
|
+
key?: string
|
|
103
|
+
message: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type ModelChoice = ScopedModel
|
|
107
|
+
|
|
108
|
+
export type ModelChoiceDiagnostic = {
|
|
109
|
+
type: "warning"
|
|
110
|
+
code: "no-match"
|
|
111
|
+
message: string
|
|
112
|
+
pattern: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const defaultAgentsConfig: AgentsConfig = {
|
|
116
|
+
...createAgentsConfigSpec().defaults
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createAgentsConfigSpec(ctx?: ModelConfigContext): ScopedConfig<RawAgentsConfig> {
|
|
120
|
+
return defineScopedConfig({
|
|
121
|
+
fileName: "xl0-pi-lovely-agents.json",
|
|
122
|
+
schema: createConfigSchema(ctx)
|
|
123
|
+
}) as ScopedConfig<RawAgentsConfig>
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function resolveAgentsConfig(config: ScopedConfig<RawAgentsConfig>): {
|
|
127
|
+
value: AgentsConfig
|
|
128
|
+
warnings: AgentsConfigWarning[]
|
|
129
|
+
} {
|
|
130
|
+
const scoped = {
|
|
131
|
+
user: { ...config.scoped.user },
|
|
132
|
+
workspace: { ...config.scoped.workspace }
|
|
133
|
+
}
|
|
134
|
+
const warnings: AgentsConfigWarning[] = [...config.warnings]
|
|
135
|
+
|
|
136
|
+
for (const scope of config.scopes) {
|
|
137
|
+
for (const key of ["maxConcurrency", "maxBashConcurrency", "maxDepth", "waitMs"] as const) {
|
|
138
|
+
const value = scoped[scope][key]
|
|
139
|
+
if (typeof value !== "number" || Number.isInteger(value)) continue
|
|
140
|
+
delete scoped[scope][key]
|
|
141
|
+
warnings.push({
|
|
142
|
+
scope,
|
|
143
|
+
path: config.path(scope),
|
|
144
|
+
key,
|
|
145
|
+
message: `/${key} must be an integer; value is ignored while resolving`
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { value: config.resolve(scoped), warnings }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function loadAgentsConfig(
|
|
154
|
+
cwd: string,
|
|
155
|
+
ctx?: ModelConfigContext
|
|
156
|
+
): {
|
|
157
|
+
value: AgentsConfig
|
|
158
|
+
warnings: AgentsConfigWarning[]
|
|
159
|
+
} {
|
|
160
|
+
return resolveAgentsConfig(createAgentsConfigSpec(ctx).load(cwd))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function resolveModelChoices(options: {
|
|
164
|
+
selections: readonly string[]
|
|
165
|
+
availableModels: readonly ScopedModel["model"][]
|
|
166
|
+
parentModel: ScopedModel["model"] | undefined
|
|
167
|
+
}): { models: ModelChoice[]; diagnostics: ModelChoiceDiagnostic[] } {
|
|
168
|
+
if (options.selections.length === 0) {
|
|
169
|
+
return options.parentModel
|
|
170
|
+
? { models: [{ model: options.parentModel }], diagnostics: [] }
|
|
171
|
+
: {
|
|
172
|
+
models: [],
|
|
173
|
+
diagnostics: [{ type: "warning", code: "no-match", message: "No current parent model is available", pattern: "" }]
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const models: ModelChoice[] = []
|
|
178
|
+
const diagnostics: ModelChoiceDiagnostic[] = []
|
|
179
|
+
for (const selection of options.selections) {
|
|
180
|
+
const model = options.availableModels.find(model => `${model.provider}/${model.id}` === selection)
|
|
181
|
+
if (model) models.push({ model })
|
|
182
|
+
else {
|
|
183
|
+
diagnostics.push({
|
|
184
|
+
type: "warning",
|
|
185
|
+
code: "no-match",
|
|
186
|
+
message: `Configured model "${selection}" is not available`,
|
|
187
|
+
pattern: selection
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { models, diagnostics }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function resolveConfiguredModels(config: AgentsConfig, ctx: ExtensionContext) {
|
|
195
|
+
const availableModels = ctx.modelRegistry.getAvailable()
|
|
196
|
+
const resolved = resolveModelChoices({
|
|
197
|
+
selections: config.models,
|
|
198
|
+
availableModels,
|
|
199
|
+
parentModel: ctx.model
|
|
200
|
+
})
|
|
201
|
+
const aliases: ModelAliasChoice[] = []
|
|
202
|
+
for (const name of Object.keys(MODEL_ALIASES) as ModelAliasName[]) {
|
|
203
|
+
const target = config[`${name}Model`]
|
|
204
|
+
if (target === DISABLED_MODEL) continue
|
|
205
|
+
const model = availableModels.find(model => `${model.provider}/${model.id}` === target)
|
|
206
|
+
if (!model) {
|
|
207
|
+
resolved.diagnostics.push({
|
|
208
|
+
type: "warning",
|
|
209
|
+
code: "no-match",
|
|
210
|
+
pattern: target,
|
|
211
|
+
message: `Model alias "${name}" targets unavailable model "${target}"`
|
|
212
|
+
})
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
aliases.push({ name, model, thinkingLevel: config[`${name}Thinking`] })
|
|
216
|
+
if (!resolved.models.some(choice => choice.model.provider === model.provider && choice.model.id === model.id)) {
|
|
217
|
+
resolved.models.push({ model })
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { ...resolved, aliases }
|
|
221
|
+
}
|