|
|
|
@@ -37,7 +37,6 @@ import { pathToFileURL, fileURLToPath } from "url"
|
|
|
|
|
import { ConfigMarkdown } from "../config/markdown"
|
|
|
|
|
import { SessionSummary } from "./summary"
|
|
|
|
|
import { NamedError } from "@opencode-ai/util/error"
|
|
|
|
|
import { fn } from "@/util/fn"
|
|
|
|
|
import { SessionProcessor } from "./processor"
|
|
|
|
|
import { TaskTool } from "@/tool/task"
|
|
|
|
|
import { Tool } from "@/tool/tool"
|
|
|
|
@@ -49,6 +48,9 @@ import { Shell } from "@/shell/shell"
|
|
|
|
|
import { Truncate } from "@/tool/truncate"
|
|
|
|
|
import { decodeDataUrl } from "@/util/data-url"
|
|
|
|
|
import { Process } from "@/util/process"
|
|
|
|
|
import { Deferred, Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
|
|
|
|
import { InstanceState } from "@/effect/instance-state"
|
|
|
|
|
import { makeRuntime } from "@/effect/run-service"
|
|
|
|
|
|
|
|
|
|
// @ts-ignore
|
|
|
|
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
|
|
|
@@ -66,30 +68,452 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
|
|
|
|
|
export namespace SessionPrompt {
|
|
|
|
|
const log = Log.create({ service: "session.prompt" })
|
|
|
|
|
|
|
|
|
|
const state = Instance.state(
|
|
|
|
|
() => {
|
|
|
|
|
const data: Record<
|
|
|
|
|
string,
|
|
|
|
|
{
|
|
|
|
|
abort: AbortController
|
|
|
|
|
callbacks: {
|
|
|
|
|
resolve(input: MessageV2.WithParts): void
|
|
|
|
|
reject(reason?: any): void
|
|
|
|
|
}[]
|
|
|
|
|
interface LoopEntry {
|
|
|
|
|
fiber?: Fiber.Fiber<MessageV2.WithParts, unknown>
|
|
|
|
|
queue: Deferred.Deferred<MessageV2.WithParts, unknown>[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface Interface {
|
|
|
|
|
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void>
|
|
|
|
|
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
|
|
|
|
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
|
|
|
|
|
readonly loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts>
|
|
|
|
|
readonly shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts>
|
|
|
|
|
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts>
|
|
|
|
|
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionPrompt") {}
|
|
|
|
|
|
|
|
|
|
export const layer = Layer.effect(
|
|
|
|
|
Service,
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const bus = yield* Bus.Service
|
|
|
|
|
const status = yield* SessionStatus.Service
|
|
|
|
|
const scope = yield* Scope.Scope
|
|
|
|
|
|
|
|
|
|
const cache = yield* InstanceState.make(
|
|
|
|
|
Effect.fn("SessionPrompt.state")(function* () {
|
|
|
|
|
const loops = new Map<string, LoopEntry>()
|
|
|
|
|
const shells = new Map<string, Fiber.Fiber<MessageV2.WithParts, unknown>>()
|
|
|
|
|
yield* Effect.addFinalizer(() =>
|
|
|
|
|
Effect.forEach(
|
|
|
|
|
[...loops.values().flatMap((e) => e.fiber ? [e.fiber] : []), ...shells.values()],
|
|
|
|
|
(fiber) => Fiber.interrupt(fiber),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
return { loops, shells }
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const assertNotBusy = Effect.fn("SessionPrompt.assertNotBusy")(function* (sessionID: SessionID) {
|
|
|
|
|
const s = yield* InstanceState.get(cache)
|
|
|
|
|
if (s.loops.has(sessionID) || s.shells.has(sessionID)) throw new Session.BusyError(sessionID)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
|
|
|
|
|
log.info("cancel", { sessionID })
|
|
|
|
|
const s = yield* InstanceState.get(cache)
|
|
|
|
|
const loopEntry = s.loops.get(sessionID)
|
|
|
|
|
const shellEntry = s.shells.get(sessionID)
|
|
|
|
|
if (!loopEntry && !shellEntry) {
|
|
|
|
|
yield* status.set(sessionID, { type: "idle" })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
> = {}
|
|
|
|
|
return data
|
|
|
|
|
},
|
|
|
|
|
async (current) => {
|
|
|
|
|
for (const item of Object.values(current)) {
|
|
|
|
|
item.abort.abort()
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
if (loopEntry) {
|
|
|
|
|
if (loopEntry.fiber) yield* Fiber.interrupt(loopEntry.fiber)
|
|
|
|
|
for (const d of loopEntry.queue) yield* Deferred.interrupt(d)
|
|
|
|
|
s.loops.delete(sessionID)
|
|
|
|
|
}
|
|
|
|
|
if (shellEntry) {
|
|
|
|
|
yield* Fiber.interrupt(shellEntry)
|
|
|
|
|
s.shells.delete(sessionID)
|
|
|
|
|
}
|
|
|
|
|
yield* status.set(sessionID, { type: "idle" })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const resolvePromptPartsE = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
|
|
|
|
|
return yield* Effect.promise(() => resolvePromptPartsImpl(template))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const promptE = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) {
|
|
|
|
|
const session = yield* Effect.promise(() => Session.get(input.sessionID))
|
|
|
|
|
yield* Effect.promise(() => SessionRevert.cleanup(session))
|
|
|
|
|
const message = yield* Effect.promise(() => createUserMessage(input))
|
|
|
|
|
yield* Effect.promise(() => Session.touch(input.sessionID))
|
|
|
|
|
|
|
|
|
|
const permissions: Permission.Ruleset = []
|
|
|
|
|
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
|
|
|
|
|
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
|
|
|
|
|
}
|
|
|
|
|
if (permissions.length > 0) {
|
|
|
|
|
session.permission = permissions
|
|
|
|
|
yield* Effect.promise(() => Session.setPermission({ sessionID: session.id, permission: permissions }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (input.noReply === true) return message
|
|
|
|
|
return yield* loopE({ sessionID: input.sessionID })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const runLoop = Effect.fn("SessionPrompt.run")(function* (sessionID: SessionID) {
|
|
|
|
|
let structured: unknown | undefined
|
|
|
|
|
let step = 0
|
|
|
|
|
const session = yield* Effect.promise(() => Session.get(sessionID))
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
yield* status.set(sessionID, { type: "busy" })
|
|
|
|
|
log.info("loop", { step, sessionID })
|
|
|
|
|
|
|
|
|
|
let msgs = yield* Effect.promise(() => MessageV2.filterCompacted(MessageV2.stream(sessionID)))
|
|
|
|
|
|
|
|
|
|
let lastUser: MessageV2.User | undefined
|
|
|
|
|
let lastAssistant: MessageV2.Assistant | undefined
|
|
|
|
|
let lastFinished: MessageV2.Assistant | undefined
|
|
|
|
|
let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
|
|
|
|
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
|
|
|
const msg = msgs[i]
|
|
|
|
|
if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User
|
|
|
|
|
if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant
|
|
|
|
|
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish)
|
|
|
|
|
lastFinished = msg.info as MessageV2.Assistant
|
|
|
|
|
if (lastUser && lastFinished) break
|
|
|
|
|
const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
|
|
|
|
|
if (task && !lastFinished) tasks.push(...task)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
|
|
|
|
|
if (
|
|
|
|
|
lastAssistant?.finish &&
|
|
|
|
|
!["tool-calls"].includes(lastAssistant.finish) &&
|
|
|
|
|
lastUser.id < lastAssistant.id
|
|
|
|
|
) {
|
|
|
|
|
log.info("exiting loop", { sessionID })
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
step++
|
|
|
|
|
if (step === 1)
|
|
|
|
|
yield* Effect.promise(() =>
|
|
|
|
|
ensureTitle({
|
|
|
|
|
session,
|
|
|
|
|
modelID: lastUser.model.modelID,
|
|
|
|
|
providerID: lastUser.model.providerID,
|
|
|
|
|
history: msgs,
|
|
|
|
|
}),
|
|
|
|
|
).pipe(Effect.ignore, Effect.forkIn(scope))
|
|
|
|
|
|
|
|
|
|
const model = yield* Effect.promise(() =>
|
|
|
|
|
Provider.getModel(lastUser!.model.providerID, lastUser!.model.modelID).catch((e) => {
|
|
|
|
|
if (Provider.ModelNotFoundError.isInstance(e)) {
|
|
|
|
|
const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : ""
|
|
|
|
|
Bus.publish(Session.Event.Error, {
|
|
|
|
|
sessionID,
|
|
|
|
|
error: new NamedError.Unknown({
|
|
|
|
|
message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`,
|
|
|
|
|
}).toObject(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
throw e
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
const task = tasks.pop()
|
|
|
|
|
|
|
|
|
|
if (task?.type === "subtask") {
|
|
|
|
|
yield* Effect.promise((signal) =>
|
|
|
|
|
handleSubtask({ task, model, lastUser: lastUser!, sessionID, session, msgs, signal }),
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (task?.type === "compaction") {
|
|
|
|
|
const result = yield* Effect.promise((signal) =>
|
|
|
|
|
SessionCompaction.process({
|
|
|
|
|
messages: msgs,
|
|
|
|
|
parentID: lastUser!.id,
|
|
|
|
|
abort: signal,
|
|
|
|
|
sessionID,
|
|
|
|
|
auto: task.auto,
|
|
|
|
|
overflow: task.overflow,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
if (result === "stop") break
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
lastFinished &&
|
|
|
|
|
lastFinished.summary !== true &&
|
|
|
|
|
(yield* Effect.promise(() => SessionCompaction.isOverflow({ tokens: lastFinished!.tokens, model })))
|
|
|
|
|
) {
|
|
|
|
|
yield* Effect.promise(() =>
|
|
|
|
|
SessionCompaction.create({ sessionID, agent: lastUser!.agent, model: lastUser!.model, auto: true }),
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const agent = yield* Effect.promise(() => Agent.get(lastUser!.agent))
|
|
|
|
|
if (!agent) {
|
|
|
|
|
const available = yield* Effect.promise(() =>
|
|
|
|
|
Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name)),
|
|
|
|
|
)
|
|
|
|
|
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
|
|
|
|
const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser!.agent}".${hint}` })
|
|
|
|
|
yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
const maxSteps = agent.steps ?? Infinity
|
|
|
|
|
const isLastStep = step >= maxSteps
|
|
|
|
|
msgs = yield* Effect.promise(() => insertReminders({ messages: msgs, agent, session }))
|
|
|
|
|
|
|
|
|
|
const msg = yield* Effect.promise(() =>
|
|
|
|
|
Session.updateMessage({
|
|
|
|
|
id: MessageID.ascending(),
|
|
|
|
|
parentID: lastUser!.id,
|
|
|
|
|
role: "assistant",
|
|
|
|
|
mode: agent.name,
|
|
|
|
|
agent: agent.name,
|
|
|
|
|
variant: lastUser!.variant,
|
|
|
|
|
path: { cwd: Instance.directory, root: Instance.worktree },
|
|
|
|
|
cost: 0,
|
|
|
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
|
|
|
modelID: model.id,
|
|
|
|
|
providerID: model.providerID,
|
|
|
|
|
time: { created: Date.now() },
|
|
|
|
|
sessionID,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
const processor = yield* Effect.promise((signal) =>
|
|
|
|
|
Promise.resolve(SessionProcessor.create({
|
|
|
|
|
assistantMessage: msg as MessageV2.Assistant,
|
|
|
|
|
sessionID,
|
|
|
|
|
model,
|
|
|
|
|
abort: signal,
|
|
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const outcome: "break" | "continue" = yield* Effect.ensuring(
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
|
|
|
|
|
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
|
|
|
|
|
|
|
|
|
const tools = yield* Effect.promise(() =>
|
|
|
|
|
resolveTools({ agent, session, model, tools: lastUser!.tools, processor, bypassAgentCheck, messages: msgs }),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (lastUser!.format?.type === "json_schema") {
|
|
|
|
|
tools["StructuredOutput"] = createStructuredOutputTool({
|
|
|
|
|
schema: lastUser!.format.schema,
|
|
|
|
|
onSuccess(output) {
|
|
|
|
|
structured = output
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser!.id })
|
|
|
|
|
|
|
|
|
|
if (step > 1 && lastFinished) {
|
|
|
|
|
for (const m of msgs) {
|
|
|
|
|
if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue
|
|
|
|
|
for (const p of m.parts) {
|
|
|
|
|
if (p.type !== "text" || p.ignored || p.synthetic) continue
|
|
|
|
|
if (!p.text.trim()) continue
|
|
|
|
|
p.text = [
|
|
|
|
|
"<system-reminder>",
|
|
|
|
|
"The user sent the following message:",
|
|
|
|
|
p.text,
|
|
|
|
|
"",
|
|
|
|
|
"Please address this message and continue with your tasks.",
|
|
|
|
|
"</system-reminder>",
|
|
|
|
|
].join("\n")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
yield* Effect.promise(() => Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }))
|
|
|
|
|
|
|
|
|
|
const [skills, env, instructions, modelMsgs] = yield* Effect.promise(() =>
|
|
|
|
|
Promise.all([
|
|
|
|
|
SystemPrompt.skills(agent),
|
|
|
|
|
SystemPrompt.environment(model),
|
|
|
|
|
InstructionPrompt.system(),
|
|
|
|
|
MessageV2.toModelMessages(msgs, model),
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
const system = [...env, ...(skills ? [skills] : []), ...instructions]
|
|
|
|
|
const format = lastUser!.format ?? { type: "text" as const }
|
|
|
|
|
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
|
|
|
|
|
const result = yield* Effect.promise((signal) =>
|
|
|
|
|
processor.process({
|
|
|
|
|
user: lastUser!,
|
|
|
|
|
agent,
|
|
|
|
|
permission: session.permission,
|
|
|
|
|
abort: signal,
|
|
|
|
|
sessionID,
|
|
|
|
|
system,
|
|
|
|
|
messages: [
|
|
|
|
|
...modelMsgs,
|
|
|
|
|
...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : []),
|
|
|
|
|
],
|
|
|
|
|
tools,
|
|
|
|
|
model,
|
|
|
|
|
toolChoice: format.type === "json_schema" ? "required" : undefined,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (structured !== undefined) {
|
|
|
|
|
processor.message.structured = structured
|
|
|
|
|
processor.message.finish = processor.message.finish ?? "stop"
|
|
|
|
|
yield* Effect.promise(() => Session.updateMessage(processor.message))
|
|
|
|
|
return "break" as const
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const finished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish)
|
|
|
|
|
if (finished && !processor.message.error) {
|
|
|
|
|
if (format.type === "json_schema") {
|
|
|
|
|
processor.message.error = new MessageV2.StructuredOutputError({
|
|
|
|
|
message: "Model did not produce structured output",
|
|
|
|
|
retries: 0,
|
|
|
|
|
}).toObject()
|
|
|
|
|
yield* Effect.promise(() => Session.updateMessage(processor.message))
|
|
|
|
|
return "break" as const
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (result === "stop") return "break" as const
|
|
|
|
|
if (result === "compact") {
|
|
|
|
|
yield* Effect.promise(() =>
|
|
|
|
|
SessionCompaction.create({
|
|
|
|
|
sessionID,
|
|
|
|
|
agent: lastUser!.agent,
|
|
|
|
|
model: lastUser!.model,
|
|
|
|
|
auto: true,
|
|
|
|
|
overflow: !processor.message.finish,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return "continue" as const
|
|
|
|
|
}),
|
|
|
|
|
Effect.sync(() => InstructionPrompt.clear(processor.message.id)),
|
|
|
|
|
)
|
|
|
|
|
if (outcome === "break") break
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SessionCompaction.prune({ sessionID })
|
|
|
|
|
return yield* Effect.promise(async () => {
|
|
|
|
|
for await (const item of MessageV2.stream(sessionID)) {
|
|
|
|
|
if (item.info.role === "user") continue
|
|
|
|
|
return item
|
|
|
|
|
}
|
|
|
|
|
throw new Error("Impossible")
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
type State = { loops: Map<string, LoopEntry>; shells: Map<string, Fiber.Fiber<MessageV2.WithParts, unknown>> }
|
|
|
|
|
|
|
|
|
|
const startLoop = Effect.fnUntraced(function* (s: State, sessionID: SessionID) {
|
|
|
|
|
const fiber = yield* runLoop(sessionID).pipe(
|
|
|
|
|
Effect.onExit((exit) =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const entry = s.loops.get(sessionID)
|
|
|
|
|
if (entry) {
|
|
|
|
|
for (const d of entry.queue) yield* Deferred.done(d, exit)
|
|
|
|
|
}
|
|
|
|
|
s.loops.delete(sessionID)
|
|
|
|
|
yield* status.set(sessionID, { type: "idle" })
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
Effect.forkChild,
|
|
|
|
|
)
|
|
|
|
|
const entry = s.loops.get(sessionID)
|
|
|
|
|
if (entry) {
|
|
|
|
|
// Queue already exists (created while shell was running) — attach fiber
|
|
|
|
|
entry.fiber = fiber
|
|
|
|
|
} else {
|
|
|
|
|
s.loops.set(sessionID, { fiber, queue: [] })
|
|
|
|
|
}
|
|
|
|
|
return yield* Fiber.join(fiber).pipe(Effect.orDie)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const loopE = Effect.fn("SessionPrompt.loop")(function* (input: z.infer<typeof LoopInput>) {
|
|
|
|
|
const s = yield* InstanceState.get(cache)
|
|
|
|
|
const existing = s.loops.get(input.sessionID)
|
|
|
|
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
const d = yield* Deferred.make<MessageV2.WithParts, unknown>()
|
|
|
|
|
existing.queue.push(d)
|
|
|
|
|
return yield* Deferred.await(d).pipe(Effect.orDie)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If a shell is running, queue — shell cleanup will start the loop
|
|
|
|
|
if (s.shells.has(input.sessionID)) {
|
|
|
|
|
const d = yield* Deferred.make<MessageV2.WithParts, unknown>()
|
|
|
|
|
s.loops.set(input.sessionID, { queue: [d] })
|
|
|
|
|
return yield* Deferred.await(d).pipe(Effect.orDie)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return yield* startLoop(s, input.sessionID)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const shellE = Effect.fn("SessionPrompt.shell")(function* (input: ShellInput) {
|
|
|
|
|
const s = yield* InstanceState.get(cache)
|
|
|
|
|
if (s.loops.has(input.sessionID) || s.shells.has(input.sessionID)) {
|
|
|
|
|
throw new Session.BusyError(input.sessionID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fiber = yield* Effect.promise((signal) => shellImpl(input, signal)).pipe(
|
|
|
|
|
Effect.ensuring(
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const s = yield* InstanceState.get(cache)
|
|
|
|
|
s.shells.delete(input.sessionID)
|
|
|
|
|
// If callers queued a loop while the shell was running, start it
|
|
|
|
|
const pending = s.loops.get(input.sessionID)
|
|
|
|
|
if (pending && pending.queue.length > 0) {
|
|
|
|
|
yield* startLoop(s, input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
|
|
|
|
|
} else {
|
|
|
|
|
yield* status.set(input.sessionID, { type: "idle" })
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
Effect.forkChild,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
s.shells.set(input.sessionID, fiber)
|
|
|
|
|
return yield* Fiber.join(fiber).pipe(Effect.orDie)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const commandE = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) {
|
|
|
|
|
const resolved = yield* Effect.promise(() => resolveCommand(input))
|
|
|
|
|
const result = yield* promptE(resolved.promptInput)
|
|
|
|
|
yield* bus.publish(Command.Event.Executed, {
|
|
|
|
|
name: input.command,
|
|
|
|
|
sessionID: input.sessionID,
|
|
|
|
|
arguments: input.arguments,
|
|
|
|
|
messageID: result.info.id,
|
|
|
|
|
})
|
|
|
|
|
return result
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return Service.of({
|
|
|
|
|
assertNotBusy,
|
|
|
|
|
cancel,
|
|
|
|
|
prompt: promptE,
|
|
|
|
|
loop: loopE,
|
|
|
|
|
shell: shellE,
|
|
|
|
|
command: commandE,
|
|
|
|
|
resolvePromptParts: resolvePromptPartsE,
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
export function assertNotBusy(sessionID: SessionID) {
|
|
|
|
|
const match = state()[sessionID]
|
|
|
|
|
if (match) throw new Session.BusyError(sessionID)
|
|
|
|
|
const defaultLayer = layer.pipe(
|
|
|
|
|
Layer.provide(SessionStatus.layer),
|
|
|
|
|
Layer.provide(Bus.layer),
|
|
|
|
|
)
|
|
|
|
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
|
|
|
|
|
|
|
|
export async function assertNotBusy(sessionID: SessionID) {
|
|
|
|
|
return runPromise((svc) => svc.assertNotBusy(sessionID))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const PromptInput = z.object({
|
|
|
|
@@ -159,36 +583,78 @@ export namespace SessionPrompt {
|
|
|
|
|
})
|
|
|
|
|
export type PromptInput = z.infer<typeof PromptInput>
|
|
|
|
|
|
|
|
|
|
export const prompt = fn(PromptInput, async (input) => {
|
|
|
|
|
const session = await Session.get(input.sessionID)
|
|
|
|
|
await SessionRevert.cleanup(session)
|
|
|
|
|
export async function prompt(input: PromptInput) {
|
|
|
|
|
return runPromise((svc) => svc.prompt(input))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const message = await createUserMessage(input)
|
|
|
|
|
await Session.touch(input.sessionID)
|
|
|
|
|
export async function resolvePromptParts(template: string) {
|
|
|
|
|
return runPromise((svc) => svc.resolvePromptParts(template))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// this is backwards compatibility for allowing `tools` to be specified when
|
|
|
|
|
// prompting
|
|
|
|
|
const permissions: Permission.Ruleset = []
|
|
|
|
|
for (const [tool, enabled] of Object.entries(input.tools ?? {})) {
|
|
|
|
|
permissions.push({
|
|
|
|
|
permission: tool,
|
|
|
|
|
action: enabled ? "allow" : "deny",
|
|
|
|
|
pattern: "*",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if (permissions.length > 0) {
|
|
|
|
|
session.permission = permissions
|
|
|
|
|
await Session.setPermission({ sessionID: session.id, permission: permissions })
|
|
|
|
|
}
|
|
|
|
|
export async function cancel(sessionID: SessionID) {
|
|
|
|
|
return runPromise((svc) => svc.cancel(sessionID))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (input.noReply === true) {
|
|
|
|
|
return message
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return loop({ sessionID: input.sessionID })
|
|
|
|
|
export const LoopInput = z.object({
|
|
|
|
|
sessionID: SessionID.zod,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
export async function resolvePromptParts(template: string): Promise<PromptInput["parts"]> {
|
|
|
|
|
export async function loop(input: z.infer<typeof LoopInput>) {
|
|
|
|
|
return runPromise((svc) => svc.loop(input))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const ShellInput = z.object({
|
|
|
|
|
sessionID: SessionID.zod,
|
|
|
|
|
agent: z.string(),
|
|
|
|
|
model: z
|
|
|
|
|
.object({
|
|
|
|
|
providerID: ProviderID.zod,
|
|
|
|
|
modelID: ModelID.zod,
|
|
|
|
|
})
|
|
|
|
|
.optional(),
|
|
|
|
|
command: z.string(),
|
|
|
|
|
})
|
|
|
|
|
export type ShellInput = z.infer<typeof ShellInput>
|
|
|
|
|
|
|
|
|
|
export async function shell(input: ShellInput) {
|
|
|
|
|
return runPromise((svc) => svc.shell(input))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const CommandInput = z.object({
|
|
|
|
|
messageID: MessageID.zod.optional(),
|
|
|
|
|
sessionID: SessionID.zod,
|
|
|
|
|
agent: z.string().optional(),
|
|
|
|
|
model: z.string().optional(),
|
|
|
|
|
arguments: z.string(),
|
|
|
|
|
command: z.string(),
|
|
|
|
|
variant: z.string().optional(),
|
|
|
|
|
parts: z
|
|
|
|
|
.array(
|
|
|
|
|
z.discriminatedUnion("type", [
|
|
|
|
|
MessageV2.FilePart.omit({
|
|
|
|
|
messageID: true,
|
|
|
|
|
sessionID: true,
|
|
|
|
|
}).partial({
|
|
|
|
|
id: true,
|
|
|
|
|
}),
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
.optional(),
|
|
|
|
|
})
|
|
|
|
|
export type CommandInput = z.infer<typeof CommandInput>
|
|
|
|
|
|
|
|
|
|
export async function command(input: CommandInput) {
|
|
|
|
|
return runPromise((svc) => svc.command(input))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function lastModelImpl(sessionID: SessionID) {
|
|
|
|
|
for await (const item of MessageV2.stream(sessionID)) {
|
|
|
|
|
if (item.info.role === "user" && item.info.model) return item.info.model
|
|
|
|
|
}
|
|
|
|
|
return Provider.defaultModel()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resolvePromptPartsImpl(template: string): Promise<PromptInput["parts"]> {
|
|
|
|
|
const parts: PromptInput["parts"] = [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
|
|
|
@@ -239,533 +705,152 @@ export namespace SessionPrompt {
|
|
|
|
|
return parts
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function start(sessionID: SessionID) {
|
|
|
|
|
const s = state()
|
|
|
|
|
if (s[sessionID]) return
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
s[sessionID] = {
|
|
|
|
|
abort: controller,
|
|
|
|
|
callbacks: [],
|
|
|
|
|
async function handleSubtask(input: {
|
|
|
|
|
task: MessageV2.SubtaskPart
|
|
|
|
|
model: Provider.Model
|
|
|
|
|
lastUser: MessageV2.User
|
|
|
|
|
sessionID: SessionID
|
|
|
|
|
session: Session.Info
|
|
|
|
|
msgs: MessageV2.WithParts[]
|
|
|
|
|
signal: AbortSignal
|
|
|
|
|
}) {
|
|
|
|
|
const { task, model, lastUser, sessionID, session, msgs, signal } = input
|
|
|
|
|
const taskTool = await TaskTool.init()
|
|
|
|
|
const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model
|
|
|
|
|
const assistantMessage = (await Session.updateMessage({
|
|
|
|
|
id: MessageID.ascending(),
|
|
|
|
|
role: "assistant",
|
|
|
|
|
parentID: lastUser.id,
|
|
|
|
|
sessionID,
|
|
|
|
|
mode: task.agent,
|
|
|
|
|
agent: task.agent,
|
|
|
|
|
variant: lastUser.variant,
|
|
|
|
|
path: { cwd: Instance.directory, root: Instance.worktree },
|
|
|
|
|
cost: 0,
|
|
|
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
|
|
|
modelID: taskModel.id,
|
|
|
|
|
providerID: taskModel.providerID,
|
|
|
|
|
time: { created: Date.now() },
|
|
|
|
|
})) as MessageV2.Assistant
|
|
|
|
|
let part = (await Session.updatePart({
|
|
|
|
|
id: PartID.ascending(),
|
|
|
|
|
messageID: assistantMessage.id,
|
|
|
|
|
sessionID: assistantMessage.sessionID,
|
|
|
|
|
type: "tool",
|
|
|
|
|
callID: ulid(),
|
|
|
|
|
tool: TaskTool.id,
|
|
|
|
|
state: {
|
|
|
|
|
status: "running",
|
|
|
|
|
input: { prompt: task.prompt, description: task.description, subagent_type: task.agent, command: task.command },
|
|
|
|
|
time: { start: Date.now() },
|
|
|
|
|
},
|
|
|
|
|
})) as MessageV2.ToolPart
|
|
|
|
|
const taskArgs = {
|
|
|
|
|
prompt: task.prompt,
|
|
|
|
|
description: task.description,
|
|
|
|
|
subagent_type: task.agent,
|
|
|
|
|
command: task.command,
|
|
|
|
|
}
|
|
|
|
|
return controller.signal
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resume(sessionID: SessionID) {
|
|
|
|
|
const s = state()
|
|
|
|
|
if (!s[sessionID]) return
|
|
|
|
|
|
|
|
|
|
return s[sessionID].abort.signal
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function cancel(sessionID: SessionID) {
|
|
|
|
|
log.info("cancel", { sessionID })
|
|
|
|
|
const s = state()
|
|
|
|
|
const match = s[sessionID]
|
|
|
|
|
if (!match) {
|
|
|
|
|
await SessionStatus.set(sessionID, { type: "idle" })
|
|
|
|
|
return
|
|
|
|
|
await Plugin.trigger("tool.execute.before", { tool: "task", sessionID, callID: part.id }, { args: taskArgs })
|
|
|
|
|
let executionError: Error | undefined
|
|
|
|
|
const taskAgent = await Agent.get(task.agent)
|
|
|
|
|
if (!taskAgent) {
|
|
|
|
|
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
|
|
|
|
|
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
|
|
|
|
const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` })
|
|
|
|
|
Bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
match.abort.abort()
|
|
|
|
|
delete s[sessionID]
|
|
|
|
|
await SessionStatus.set(sessionID, { type: "idle" })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const LoopInput = z.object({
|
|
|
|
|
sessionID: SessionID.zod,
|
|
|
|
|
resume_existing: z.boolean().optional(),
|
|
|
|
|
})
|
|
|
|
|
export const loop = fn(LoopInput, async (input) => {
|
|
|
|
|
const { sessionID, resume_existing } = input
|
|
|
|
|
|
|
|
|
|
const abort = resume_existing ? resume(sessionID) : start(sessionID)
|
|
|
|
|
if (!abort) {
|
|
|
|
|
return new Promise<MessageV2.WithParts>((resolve, reject) => {
|
|
|
|
|
const callbacks = state()[sessionID].callbacks
|
|
|
|
|
callbacks.push({ resolve, reject })
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await using _ = defer(() => cancel(sessionID))
|
|
|
|
|
|
|
|
|
|
// Structured output state
|
|
|
|
|
// Note: On session resumption, state is reset but outputFormat is preserved
|
|
|
|
|
// on the user message and will be retrieved from lastUser below
|
|
|
|
|
let structuredOutput: unknown | undefined
|
|
|
|
|
|
|
|
|
|
let step = 0
|
|
|
|
|
const session = await Session.get(sessionID)
|
|
|
|
|
while (true) {
|
|
|
|
|
await SessionStatus.set(sessionID, { type: "busy" })
|
|
|
|
|
log.info("loop", { step, sessionID })
|
|
|
|
|
if (abort.aborted) break
|
|
|
|
|
let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
|
|
|
|
|
|
|
|
|
|
let lastUser: MessageV2.User | undefined
|
|
|
|
|
let lastAssistant: MessageV2.Assistant | undefined
|
|
|
|
|
let lastFinished: MessageV2.Assistant | undefined
|
|
|
|
|
let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
|
|
|
|
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
|
|
|
const msg = msgs[i]
|
|
|
|
|
if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User
|
|
|
|
|
if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant
|
|
|
|
|
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish)
|
|
|
|
|
lastFinished = msg.info as MessageV2.Assistant
|
|
|
|
|
if (lastUser && lastFinished) break
|
|
|
|
|
const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
|
|
|
|
|
if (task && !lastFinished) {
|
|
|
|
|
tasks.push(...task)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
|
|
|
|
|
if (
|
|
|
|
|
lastAssistant?.finish &&
|
|
|
|
|
![
|
|
|
|
|
"tool-calls",
|
|
|
|
|
// in v6 unknown became other but other existed in v5 too and was distinctly different
|
|
|
|
|
// I think there are certain providers that used to have bad stop reasons, not rlly sure which
|
|
|
|
|
// ones if any still have this?
|
|
|
|
|
// "unknown",
|
|
|
|
|
].includes(lastAssistant.finish) &&
|
|
|
|
|
lastUser.id < lastAssistant.id
|
|
|
|
|
) {
|
|
|
|
|
log.info("exiting loop", { sessionID })
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
step++
|
|
|
|
|
if (step === 1)
|
|
|
|
|
ensureTitle({
|
|
|
|
|
session,
|
|
|
|
|
modelID: lastUser.model.modelID,
|
|
|
|
|
providerID: lastUser.model.providerID,
|
|
|
|
|
history: msgs,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID).catch((e) => {
|
|
|
|
|
if (Provider.ModelNotFoundError.isInstance(e)) {
|
|
|
|
|
const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : ""
|
|
|
|
|
Bus.publish(Session.Event.Error, {
|
|
|
|
|
sessionID,
|
|
|
|
|
error: new NamedError.Unknown({
|
|
|
|
|
message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`,
|
|
|
|
|
}).toObject(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
throw e
|
|
|
|
|
})
|
|
|
|
|
const task = tasks.pop()
|
|
|
|
|
|
|
|
|
|
// pending subtask
|
|
|
|
|
// TODO: centralize "invoke tool" logic
|
|
|
|
|
if (task?.type === "subtask") {
|
|
|
|
|
const taskTool = await TaskTool.init()
|
|
|
|
|
const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model
|
|
|
|
|
const assistantMessage = (await Session.updateMessage({
|
|
|
|
|
id: MessageID.ascending(),
|
|
|
|
|
role: "assistant",
|
|
|
|
|
parentID: lastUser.id,
|
|
|
|
|
sessionID,
|
|
|
|
|
mode: task.agent,
|
|
|
|
|
agent: task.agent,
|
|
|
|
|
variant: lastUser.variant,
|
|
|
|
|
path: {
|
|
|
|
|
cwd: Instance.directory,
|
|
|
|
|
root: Instance.worktree,
|
|
|
|
|
},
|
|
|
|
|
cost: 0,
|
|
|
|
|
tokens: {
|
|
|
|
|
input: 0,
|
|
|
|
|
output: 0,
|
|
|
|
|
reasoning: 0,
|
|
|
|
|
cache: { read: 0, write: 0 },
|
|
|
|
|
},
|
|
|
|
|
modelID: taskModel.id,
|
|
|
|
|
providerID: taskModel.providerID,
|
|
|
|
|
time: {
|
|
|
|
|
created: Date.now(),
|
|
|
|
|
},
|
|
|
|
|
})) as MessageV2.Assistant
|
|
|
|
|
let part = (await Session.updatePart({
|
|
|
|
|
id: PartID.ascending(),
|
|
|
|
|
messageID: assistantMessage.id,
|
|
|
|
|
sessionID: assistantMessage.sessionID,
|
|
|
|
|
const taskCtx: Tool.Context = {
|
|
|
|
|
agent: task.agent,
|
|
|
|
|
messageID: assistantMessage.id,
|
|
|
|
|
sessionID,
|
|
|
|
|
abort: signal,
|
|
|
|
|
callID: part.callID,
|
|
|
|
|
extra: { bypassAgentCheck: true },
|
|
|
|
|
messages: msgs,
|
|
|
|
|
async metadata(val) {
|
|
|
|
|
part = (await Session.updatePart({
|
|
|
|
|
...part,
|
|
|
|
|
type: "tool",
|
|
|
|
|
callID: ulid(),
|
|
|
|
|
tool: TaskTool.id,
|
|
|
|
|
state: {
|
|
|
|
|
status: "running",
|
|
|
|
|
input: {
|
|
|
|
|
prompt: task.prompt,
|
|
|
|
|
description: task.description,
|
|
|
|
|
subagent_type: task.agent,
|
|
|
|
|
command: task.command,
|
|
|
|
|
},
|
|
|
|
|
time: {
|
|
|
|
|
start: Date.now(),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})) as MessageV2.ToolPart
|
|
|
|
|
const taskArgs = {
|
|
|
|
|
prompt: task.prompt,
|
|
|
|
|
description: task.description,
|
|
|
|
|
subagent_type: task.agent,
|
|
|
|
|
command: task.command,
|
|
|
|
|
}
|
|
|
|
|
await Plugin.trigger(
|
|
|
|
|
"tool.execute.before",
|
|
|
|
|
{
|
|
|
|
|
tool: "task",
|
|
|
|
|
sessionID,
|
|
|
|
|
callID: part.id,
|
|
|
|
|
},
|
|
|
|
|
{ args: taskArgs },
|
|
|
|
|
)
|
|
|
|
|
let executionError: Error | undefined
|
|
|
|
|
const taskAgent = await Agent.get(task.agent)
|
|
|
|
|
if (!taskAgent) {
|
|
|
|
|
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
|
|
|
|
|
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
|
|
|
|
const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` })
|
|
|
|
|
Bus.publish(Session.Event.Error, {
|
|
|
|
|
sessionID,
|
|
|
|
|
error: error.toObject(),
|
|
|
|
|
})
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
const taskCtx: Tool.Context = {
|
|
|
|
|
agent: task.agent,
|
|
|
|
|
messageID: assistantMessage.id,
|
|
|
|
|
sessionID: sessionID,
|
|
|
|
|
abort,
|
|
|
|
|
callID: part.callID,
|
|
|
|
|
extra: { bypassAgentCheck: true },
|
|
|
|
|
messages: msgs,
|
|
|
|
|
async metadata(input) {
|
|
|
|
|
part = (await Session.updatePart({
|
|
|
|
|
...part,
|
|
|
|
|
type: "tool",
|
|
|
|
|
state: {
|
|
|
|
|
...part.state,
|
|
|
|
|
...input,
|
|
|
|
|
},
|
|
|
|
|
} satisfies MessageV2.ToolPart)) as MessageV2.ToolPart
|
|
|
|
|
},
|
|
|
|
|
async ask(req) {
|
|
|
|
|
await Permission.ask({
|
|
|
|
|
...req,
|
|
|
|
|
sessionID: sessionID,
|
|
|
|
|
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => {
|
|
|
|
|
executionError = error
|
|
|
|
|
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
|
|
|
|
|
return undefined
|
|
|
|
|
})
|
|
|
|
|
const attachments = result?.attachments?.map((attachment) => ({
|
|
|
|
|
...attachment,
|
|
|
|
|
id: PartID.ascending(),
|
|
|
|
|
state: { ...part.state, ...val },
|
|
|
|
|
} satisfies MessageV2.ToolPart)) as MessageV2.ToolPart
|
|
|
|
|
},
|
|
|
|
|
async ask(req) {
|
|
|
|
|
await Permission.ask({
|
|
|
|
|
...req,
|
|
|
|
|
sessionID,
|
|
|
|
|
messageID: assistantMessage.id,
|
|
|
|
|
}))
|
|
|
|
|
await Plugin.trigger(
|
|
|
|
|
"tool.execute.after",
|
|
|
|
|
{
|
|
|
|
|
tool: "task",
|
|
|
|
|
sessionID,
|
|
|
|
|
callID: part.id,
|
|
|
|
|
args: taskArgs,
|
|
|
|
|
},
|
|
|
|
|
result,
|
|
|
|
|
)
|
|
|
|
|
assistantMessage.finish = "tool-calls"
|
|
|
|
|
assistantMessage.time.completed = Date.now()
|
|
|
|
|
await Session.updateMessage(assistantMessage)
|
|
|
|
|
if (result && part.state.status === "running") {
|
|
|
|
|
await Session.updatePart({
|
|
|
|
|
...part,
|
|
|
|
|
state: {
|
|
|
|
|
status: "completed",
|
|
|
|
|
input: part.state.input,
|
|
|
|
|
title: result.title,
|
|
|
|
|
metadata: result.metadata,
|
|
|
|
|
output: result.output,
|
|
|
|
|
attachments,
|
|
|
|
|
time: {
|
|
|
|
|
...part.state.time,
|
|
|
|
|
end: Date.now(),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
} satisfies MessageV2.ToolPart)
|
|
|
|
|
}
|
|
|
|
|
if (!result) {
|
|
|
|
|
await Session.updatePart({
|
|
|
|
|
...part,
|
|
|
|
|
state: {
|
|
|
|
|
status: "error",
|
|
|
|
|
error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed",
|
|
|
|
|
time: {
|
|
|
|
|
start: part.state.status === "running" ? part.state.time.start : Date.now(),
|
|
|
|
|
end: Date.now(),
|
|
|
|
|
},
|
|
|
|
|
metadata: "metadata" in part.state ? part.state.metadata : undefined,
|
|
|
|
|
input: part.state.input,
|
|
|
|
|
},
|
|
|
|
|
} satisfies MessageV2.ToolPart)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (task.command) {
|
|
|
|
|
// Add synthetic user message to prevent certain reasoning models from erroring
|
|
|
|
|
// If we create assistant messages w/ out user ones following mid loop thinking signatures
|
|
|
|
|
// will be missing and it can cause errors for models like gemini for example
|
|
|
|
|
const summaryUserMsg: MessageV2.User = {
|
|
|
|
|
id: MessageID.ascending(),
|
|
|
|
|
sessionID,
|
|
|
|
|
role: "user",
|
|
|
|
|
time: {
|
|
|
|
|
created: Date.now(),
|
|
|
|
|
},
|
|
|
|
|
agent: lastUser.agent,
|
|
|
|
|
model: lastUser.model,
|
|
|
|
|
}
|
|
|
|
|
await Session.updateMessage(summaryUserMsg)
|
|
|
|
|
await Session.updatePart({
|
|
|
|
|
id: PartID.ascending(),
|
|
|
|
|
messageID: summaryUserMsg.id,
|
|
|
|
|
sessionID,
|
|
|
|
|
type: "text",
|
|
|
|
|
text: "Summarize the task tool output above and continue with your task.",
|
|
|
|
|
synthetic: true,
|
|
|
|
|
} satisfies MessageV2.TextPart)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// pending compaction
|
|
|
|
|
if (task?.type === "compaction") {
|
|
|
|
|
const result = await SessionCompaction.process({
|
|
|
|
|
messages: msgs,
|
|
|
|
|
parentID: lastUser.id,
|
|
|
|
|
abort,
|
|
|
|
|
sessionID,
|
|
|
|
|
auto: task.auto,
|
|
|
|
|
overflow: task.overflow,
|
|
|
|
|
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
|
|
|
|
|
})
|
|
|
|
|
if (result === "stop") break
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// context overflow, needs compaction
|
|
|
|
|
if (
|
|
|
|
|
lastFinished &&
|
|
|
|
|
lastFinished.summary !== true &&
|
|
|
|
|
(await SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model }))
|
|
|
|
|
) {
|
|
|
|
|
await SessionCompaction.create({
|
|
|
|
|
sessionID,
|
|
|
|
|
agent: lastUser.agent,
|
|
|
|
|
model: lastUser.model,
|
|
|
|
|
auto: true,
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// normal processing
|
|
|
|
|
const agent = await Agent.get(lastUser.agent)
|
|
|
|
|
if (!agent) {
|
|
|
|
|
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
|
|
|
|
|
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
|
|
|
|
const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })
|
|
|
|
|
Bus.publish(Session.Event.Error, {
|
|
|
|
|
sessionID,
|
|
|
|
|
error: error.toObject(),
|
|
|
|
|
})
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
const maxSteps = agent.steps ?? Infinity
|
|
|
|
|
const isLastStep = step >= maxSteps
|
|
|
|
|
msgs = await insertReminders({
|
|
|
|
|
messages: msgs,
|
|
|
|
|
agent,
|
|
|
|
|
session,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const processor = await SessionProcessor.create({
|
|
|
|
|
assistantMessage: (await Session.updateMessage({
|
|
|
|
|
id: MessageID.ascending(),
|
|
|
|
|
parentID: lastUser.id,
|
|
|
|
|
role: "assistant",
|
|
|
|
|
mode: agent.name,
|
|
|
|
|
agent: agent.name,
|
|
|
|
|
variant: lastUser.variant,
|
|
|
|
|
path: {
|
|
|
|
|
cwd: Instance.directory,
|
|
|
|
|
root: Instance.worktree,
|
|
|
|
|
},
|
|
|
|
|
cost: 0,
|
|
|
|
|
tokens: {
|
|
|
|
|
input: 0,
|
|
|
|
|
output: 0,
|
|
|
|
|
reasoning: 0,
|
|
|
|
|
cache: { read: 0, write: 0 },
|
|
|
|
|
},
|
|
|
|
|
modelID: model.id,
|
|
|
|
|
providerID: model.providerID,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => {
|
|
|
|
|
executionError = error
|
|
|
|
|
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
|
|
|
|
|
return undefined
|
|
|
|
|
})
|
|
|
|
|
const attachments = result?.attachments?.map((attachment) => ({
|
|
|
|
|
...attachment,
|
|
|
|
|
id: PartID.ascending(),
|
|
|
|
|
sessionID,
|
|
|
|
|
messageID: assistantMessage.id,
|
|
|
|
|
}))
|
|
|
|
|
await Plugin.trigger(
|
|
|
|
|
"tool.execute.after",
|
|
|
|
|
{ tool: "task", sessionID, callID: part.id, args: taskArgs },
|
|
|
|
|
result,
|
|
|
|
|
)
|
|
|
|
|
assistantMessage.finish = "tool-calls"
|
|
|
|
|
assistantMessage.time.completed = Date.now()
|
|
|
|
|
await Session.updateMessage(assistantMessage)
|
|
|
|
|
if (result && part.state.status === "running") {
|
|
|
|
|
await Session.updatePart({
|
|
|
|
|
...part,
|
|
|
|
|
state: {
|
|
|
|
|
status: "completed",
|
|
|
|
|
input: part.state.input,
|
|
|
|
|
title: result.title,
|
|
|
|
|
metadata: result.metadata,
|
|
|
|
|
output: result.output,
|
|
|
|
|
attachments,
|
|
|
|
|
time: { ...part.state.time, end: Date.now() },
|
|
|
|
|
},
|
|
|
|
|
} satisfies MessageV2.ToolPart)
|
|
|
|
|
}
|
|
|
|
|
if (!result) {
|
|
|
|
|
await Session.updatePart({
|
|
|
|
|
...part,
|
|
|
|
|
state: {
|
|
|
|
|
status: "error",
|
|
|
|
|
error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed",
|
|
|
|
|
time: {
|
|
|
|
|
created: Date.now(),
|
|
|
|
|
start: part.state.status === "running" ? part.state.time.start : Date.now(),
|
|
|
|
|
end: Date.now(),
|
|
|
|
|
},
|
|
|
|
|
sessionID,
|
|
|
|
|
})) as MessageV2.Assistant,
|
|
|
|
|
sessionID: sessionID,
|
|
|
|
|
model,
|
|
|
|
|
abort,
|
|
|
|
|
})
|
|
|
|
|
using _ = defer(() => InstructionPrompt.clear(processor.message.id))
|
|
|
|
|
|
|
|
|
|
// Check if user explicitly invoked an agent via @ in this turn
|
|
|
|
|
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
|
|
|
|
|
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
|
|
|
|
|
|
|
|
|
const tools = await resolveTools({
|
|
|
|
|
agent,
|
|
|
|
|
session,
|
|
|
|
|
model,
|
|
|
|
|
tools: lastUser.tools,
|
|
|
|
|
processor,
|
|
|
|
|
bypassAgentCheck,
|
|
|
|
|
messages: msgs,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Inject StructuredOutput tool if JSON schema mode enabled
|
|
|
|
|
if (lastUser.format?.type === "json_schema") {
|
|
|
|
|
tools["StructuredOutput"] = createStructuredOutputTool({
|
|
|
|
|
schema: lastUser.format.schema,
|
|
|
|
|
onSuccess(output) {
|
|
|
|
|
structuredOutput = output
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (step === 1) {
|
|
|
|
|
SessionSummary.summarize({
|
|
|
|
|
sessionID: sessionID,
|
|
|
|
|
messageID: lastUser.id,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ephemerally wrap queued user messages with a reminder to stay on track
|
|
|
|
|
if (step > 1 && lastFinished) {
|
|
|
|
|
for (const msg of msgs) {
|
|
|
|
|
if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue
|
|
|
|
|
for (const part of msg.parts) {
|
|
|
|
|
if (part.type !== "text" || part.ignored || part.synthetic) continue
|
|
|
|
|
if (!part.text.trim()) continue
|
|
|
|
|
part.text = [
|
|
|
|
|
"<system-reminder>",
|
|
|
|
|
"The user sent the following message:",
|
|
|
|
|
part.text,
|
|
|
|
|
"",
|
|
|
|
|
"Please address this message and continue with your tasks.",
|
|
|
|
|
"</system-reminder>",
|
|
|
|
|
].join("\n")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
|
|
|
|
|
|
|
|
|
// Build system prompt, adding structured output instruction if needed
|
|
|
|
|
const skills = await SystemPrompt.skills(agent)
|
|
|
|
|
const system = [
|
|
|
|
|
...(await SystemPrompt.environment(model)),
|
|
|
|
|
...(skills ? [skills] : []),
|
|
|
|
|
...(await InstructionPrompt.system()),
|
|
|
|
|
]
|
|
|
|
|
const format = lastUser.format ?? { type: "text" }
|
|
|
|
|
if (format.type === "json_schema") {
|
|
|
|
|
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await processor.process({
|
|
|
|
|
user: lastUser,
|
|
|
|
|
agent,
|
|
|
|
|
permission: session.permission,
|
|
|
|
|
abort,
|
|
|
|
|
metadata: "metadata" in part.state ? part.state.metadata : undefined,
|
|
|
|
|
input: part.state.input,
|
|
|
|
|
},
|
|
|
|
|
} satisfies MessageV2.ToolPart)
|
|
|
|
|
}
|
|
|
|
|
if (task.command) {
|
|
|
|
|
const summaryUserMsg: MessageV2.User = {
|
|
|
|
|
id: MessageID.ascending(),
|
|
|
|
|
sessionID,
|
|
|
|
|
system,
|
|
|
|
|
messages: [
|
|
|
|
|
...(await MessageV2.toModelMessages(msgs, model)),
|
|
|
|
|
...(isLastStep
|
|
|
|
|
? [
|
|
|
|
|
{
|
|
|
|
|
role: "assistant" as const,
|
|
|
|
|
content: MAX_STEPS,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
: []),
|
|
|
|
|
],
|
|
|
|
|
tools,
|
|
|
|
|
model,
|
|
|
|
|
toolChoice: format.type === "json_schema" ? "required" : undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// If structured output was captured, save it and exit immediately
|
|
|
|
|
// This takes priority because the StructuredOutput tool was called successfully
|
|
|
|
|
if (structuredOutput !== undefined) {
|
|
|
|
|
processor.message.structured = structuredOutput
|
|
|
|
|
processor.message.finish = processor.message.finish ?? "stop"
|
|
|
|
|
await Session.updateMessage(processor.message)
|
|
|
|
|
break
|
|
|
|
|
role: "user",
|
|
|
|
|
time: { created: Date.now() },
|
|
|
|
|
agent: lastUser.agent,
|
|
|
|
|
model: lastUser.model,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if model finished (finish reason is not "tool-calls" or "unknown")
|
|
|
|
|
const modelFinished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish)
|
|
|
|
|
|
|
|
|
|
if (modelFinished && !processor.message.error) {
|
|
|
|
|
if (format.type === "json_schema") {
|
|
|
|
|
// Model stopped without calling StructuredOutput tool
|
|
|
|
|
processor.message.error = new MessageV2.StructuredOutputError({
|
|
|
|
|
message: "Model did not produce structured output",
|
|
|
|
|
retries: 0,
|
|
|
|
|
}).toObject()
|
|
|
|
|
await Session.updateMessage(processor.message)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (result === "stop") break
|
|
|
|
|
if (result === "compact") {
|
|
|
|
|
await SessionCompaction.create({
|
|
|
|
|
sessionID,
|
|
|
|
|
agent: lastUser.agent,
|
|
|
|
|
model: lastUser.model,
|
|
|
|
|
auto: true,
|
|
|
|
|
overflow: !processor.message.finish,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
await Session.updateMessage(summaryUserMsg)
|
|
|
|
|
await Session.updatePart({
|
|
|
|
|
id: PartID.ascending(),
|
|
|
|
|
messageID: summaryUserMsg.id,
|
|
|
|
|
sessionID,
|
|
|
|
|
type: "text",
|
|
|
|
|
text: "Summarize the task tool output above and continue with your task.",
|
|
|
|
|
synthetic: true,
|
|
|
|
|
} satisfies MessageV2.TextPart)
|
|
|
|
|
}
|
|
|
|
|
SessionCompaction.prune({ sessionID })
|
|
|
|
|
for await (const item of MessageV2.stream(sessionID)) {
|
|
|
|
|
if (item.info.role === "user") continue
|
|
|
|
|
const queued = state()[sessionID]?.callbacks ?? []
|
|
|
|
|
for (const q of queued) {
|
|
|
|
|
q.resolve(item)
|
|
|
|
|
}
|
|
|
|
|
return item
|
|
|
|
|
}
|
|
|
|
|
throw new Error("Impossible")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async function lastModel(sessionID: SessionID) {
|
|
|
|
|
for await (const item of MessageV2.stream(sessionID)) {
|
|
|
|
|
if (item.info.role === "user" && item.info.model) return item.info.model
|
|
|
|
|
}
|
|
|
|
|
return Provider.defaultModel()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @internal Exported for testing */
|
|
|
|
@@ -1004,7 +1089,7 @@ export namespace SessionPrompt {
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
|
|
|
|
|
const model = input.model ?? agent.model ?? (await lastModelImpl(input.sessionID))
|
|
|
|
|
const full =
|
|
|
|
|
!input.variant && agent.variant
|
|
|
|
|
? await Provider.getModel(model.providerID, model.modelID).catch(() => undefined)
|
|
|
|
@@ -1533,37 +1618,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|
|
|
|
return input.messages
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const ShellInput = z.object({
|
|
|
|
|
sessionID: SessionID.zod,
|
|
|
|
|
agent: z.string(),
|
|
|
|
|
model: z
|
|
|
|
|
.object({
|
|
|
|
|
providerID: ProviderID.zod,
|
|
|
|
|
modelID: ModelID.zod,
|
|
|
|
|
})
|
|
|
|
|
.optional(),
|
|
|
|
|
command: z.string(),
|
|
|
|
|
})
|
|
|
|
|
export type ShellInput = z.infer<typeof ShellInput>
|
|
|
|
|
export async function shell(input: ShellInput) {
|
|
|
|
|
const abort = start(input.sessionID)
|
|
|
|
|
if (!abort) {
|
|
|
|
|
throw new Session.BusyError(input.sessionID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
using _ = defer(() => {
|
|
|
|
|
// If no queued callbacks, cancel (the default)
|
|
|
|
|
const callbacks = state()[input.sessionID]?.callbacks ?? []
|
|
|
|
|
if (callbacks.length === 0) {
|
|
|
|
|
cancel(input.sessionID)
|
|
|
|
|
} else {
|
|
|
|
|
// Otherwise, trigger the session loop to process queued items
|
|
|
|
|
loop({ sessionID: input.sessionID, resume_existing: true }).catch((error) => {
|
|
|
|
|
log.error("session loop failed to resume after shell command", { sessionID: input.sessionID, error })
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
async function shellImpl(input: ShellInput, signal: AbortSignal): Promise<MessageV2.WithParts> {
|
|
|
|
|
const session = await Session.get(input.sessionID)
|
|
|
|
|
if (session.revert) {
|
|
|
|
|
await SessionRevert.cleanup(session)
|
|
|
|
@@ -1579,7 +1634,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|
|
|
|
})
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
|
|
|
|
|
const model = input.model ?? agent.model ?? (await lastModelImpl(input.sessionID))
|
|
|
|
|
const userMsg: MessageV2.User = {
|
|
|
|
|
id: MessageID.ascending(),
|
|
|
|
|
sessionID: input.sessionID,
|
|
|
|
@@ -1647,9 +1702,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
await Session.updatePart(part)
|
|
|
|
|
const shell = Shell.preferred()
|
|
|
|
|
const sh = Shell.preferred()
|
|
|
|
|
const shellName = (
|
|
|
|
|
process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell)
|
|
|
|
|
process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)
|
|
|
|
|
).toLowerCase()
|
|
|
|
|
|
|
|
|
|
const invocations: Record<string, { args: string[] }> = {
|
|
|
|
@@ -1708,7 +1763,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|
|
|
|
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
|
|
|
|
{ env: {} },
|
|
|
|
|
)
|
|
|
|
|
const proc = spawn(shell, args, {
|
|
|
|
|
const proc = spawn(sh, args, {
|
|
|
|
|
cwd,
|
|
|
|
|
detached: process.platform !== "win32",
|
|
|
|
|
windowsHide: process.platform === "win32",
|
|
|
|
@@ -1749,7 +1804,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|
|
|
|
|
|
|
|
|
const kill = () => Shell.killTree(proc, { exited: () => exited })
|
|
|
|
|
|
|
|
|
|
if (abort.aborted) {
|
|
|
|
|
if (signal.aborted) {
|
|
|
|
|
aborted = true
|
|
|
|
|
await kill()
|
|
|
|
|
}
|
|
|
|
@@ -1759,12 +1814,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|
|
|
|
void kill()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
abort.addEventListener("abort", abortHandler, { once: true })
|
|
|
|
|
signal.addEventListener("abort", abortHandler, { once: true })
|
|
|
|
|
|
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
|
proc.on("close", () => {
|
|
|
|
|
exited = true
|
|
|
|
|
abort.removeEventListener("abort", abortHandler)
|
|
|
|
|
signal.removeEventListener("abort", abortHandler)
|
|
|
|
|
resolve()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
@@ -1794,43 +1849,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|
|
|
|
return { info: msg, parts: [part] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const CommandInput = z.object({
|
|
|
|
|
messageID: MessageID.zod.optional(),
|
|
|
|
|
sessionID: SessionID.zod,
|
|
|
|
|
agent: z.string().optional(),
|
|
|
|
|
model: z.string().optional(),
|
|
|
|
|
arguments: z.string(),
|
|
|
|
|
command: z.string(),
|
|
|
|
|
variant: z.string().optional(),
|
|
|
|
|
parts: z
|
|
|
|
|
.array(
|
|
|
|
|
z.discriminatedUnion("type", [
|
|
|
|
|
MessageV2.FilePart.omit({
|
|
|
|
|
messageID: true,
|
|
|
|
|
sessionID: true,
|
|
|
|
|
}).partial({
|
|
|
|
|
id: true,
|
|
|
|
|
}),
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
.optional(),
|
|
|
|
|
})
|
|
|
|
|
export type CommandInput = z.infer<typeof CommandInput>
|
|
|
|
|
const bashRegex = /!`([^`]+)`/g
|
|
|
|
|
// Match [Image N] as single token, quoted strings, or non-space sequences
|
|
|
|
|
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
|
|
|
|
const placeholderRegex = /\$(\d+)/g
|
|
|
|
|
const quoteTrimRegex = /^["']|["']$/g
|
|
|
|
|
/**
|
|
|
|
|
* Regular expression to match @ file references in text
|
|
|
|
|
* Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks
|
|
|
|
|
* Does not match when preceded by word characters or backticks (to avoid email addresses and quoted references)
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export async function command(input: CommandInput) {
|
|
|
|
|
async function resolveCommand(input: CommandInput): Promise<{ promptInput: PromptInput }> {
|
|
|
|
|
log.info("command", input)
|
|
|
|
|
const command = await Command.get(input.command)
|
|
|
|
|
if (!command) {
|
|
|
|
|
const cmd = await Command.get(input.command)
|
|
|
|
|
if (!cmd) {
|
|
|
|
|
const available = await Command.list().then((cmds) => cmds.map((c) => c.name))
|
|
|
|
|
const hint = available.length ? ` Available commands: ${available.join(", ")}` : ""
|
|
|
|
|
const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` })
|
|
|
|
@@ -1840,12 +1868,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|
|
|
|
})
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())
|
|
|
|
|
const agentName = cmd.agent ?? input.agent ?? (await Agent.defaultAgent())
|
|
|
|
|
|
|
|
|
|
const raw = input.arguments.match(argsRegex) ?? []
|
|
|
|
|
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
|
|
|
|
|
|
|
|
|
|
const templateCommand = await command.template
|
|
|
|
|
const templateCommand = await cmd.template
|
|
|
|
|
|
|
|
|
|
const placeholders = templateCommand.match(placeholderRegex) ?? []
|
|
|
|
|
let last = 0
|
|
|
|
@@ -1854,7 +1882,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|
|
|
|
if (value > last) last = value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Let the final placeholder swallow any extra arguments so prompts read naturally
|
|
|
|
|
const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => {
|
|
|
|
|
const position = Number(index)
|
|
|
|
|
const argIndex = position - 1
|
|
|
|
@@ -1865,8 +1892,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|
|
|
|
const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS")
|
|
|
|
|
let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)
|
|
|
|
|
|
|
|
|
|
// If command doesn't explicitly handle arguments (no $N or $ARGUMENTS placeholders)
|
|
|
|
|
// but user provided arguments, append them to the template
|
|
|
|
|
if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
|
|
|
|
|
template = template + "\n\n" + input.arguments
|
|
|
|
|
}
|
|
|
|
@@ -1886,17 +1911,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|
|
|
|
template = template.trim()
|
|
|
|
|
|
|
|
|
|
const taskModel = await (async () => {
|
|
|
|
|
if (command.model) {
|
|
|
|
|
return Provider.parseModel(command.model)
|
|
|
|
|
}
|
|
|
|
|
if (command.agent) {
|
|
|
|
|
const cmdAgent = await Agent.get(command.agent)
|
|
|
|
|
if (cmdAgent?.model) {
|
|
|
|
|
return cmdAgent.model
|
|
|
|
|
}
|
|
|
|
|
if (cmd.model) return Provider.parseModel(cmd.model)
|
|
|
|
|
if (cmd.agent) {
|
|
|
|
|
const cmdAgent = await Agent.get(cmd.agent)
|
|
|
|
|
if (cmdAgent?.model) return cmdAgent.model
|
|
|
|
|
}
|
|
|
|
|
if (input.model) return Provider.parseModel(input.model)
|
|
|
|
|
return await lastModel(input.sessionID)
|
|
|
|
|
return await lastModelImpl(input.sessionID)
|
|
|
|
|
})()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
@@ -1924,20 +1945,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const templateParts = await resolvePromptParts(template)
|
|
|
|
|
const isSubtask = (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
|
|
|
|
|
const templateParts = await resolvePromptPartsImpl(template)
|
|
|
|
|
const isSubtask = (agent.mode === "subagent" && cmd.subtask !== false) || cmd.subtask === true
|
|
|
|
|
const parts = isSubtask
|
|
|
|
|
? [
|
|
|
|
|
{
|
|
|
|
|
type: "subtask" as const,
|
|
|
|
|
agent: agent.name,
|
|
|
|
|
description: command.description ?? "",
|
|
|
|
|
description: cmd.description ?? "",
|
|
|
|
|
command: input.command,
|
|
|
|
|
model: {
|
|
|
|
|
providerID: taskModel.providerID,
|
|
|
|
|
modelID: taskModel.modelID,
|
|
|
|
|
},
|
|
|
|
|
// TODO: how can we make task tool accept a more complex input?
|
|
|
|
|
model: { providerID: taskModel.providerID, modelID: taskModel.modelID },
|
|
|
|
|
prompt: templateParts.find((y) => y.type === "text")?.text ?? "",
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
@@ -1947,36 +1964,25 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|
|
|
|
const userModel = isSubtask
|
|
|
|
|
? input.model
|
|
|
|
|
? Provider.parseModel(input.model)
|
|
|
|
|
: await lastModel(input.sessionID)
|
|
|
|
|
: await lastModelImpl(input.sessionID)
|
|
|
|
|
: taskModel
|
|
|
|
|
|
|
|
|
|
await Plugin.trigger(
|
|
|
|
|
"command.execute.before",
|
|
|
|
|
{
|
|
|
|
|
command: input.command,
|
|
|
|
|
sessionID: input.sessionID,
|
|
|
|
|
arguments: input.arguments,
|
|
|
|
|
},
|
|
|
|
|
{ command: input.command, sessionID: input.sessionID, arguments: input.arguments },
|
|
|
|
|
{ parts },
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const result = (await prompt({
|
|
|
|
|
sessionID: input.sessionID,
|
|
|
|
|
messageID: input.messageID,
|
|
|
|
|
model: userModel,
|
|
|
|
|
agent: userAgent,
|
|
|
|
|
parts,
|
|
|
|
|
variant: input.variant,
|
|
|
|
|
})) as MessageV2.WithParts
|
|
|
|
|
|
|
|
|
|
Bus.publish(Command.Event.Executed, {
|
|
|
|
|
name: input.command,
|
|
|
|
|
sessionID: input.sessionID,
|
|
|
|
|
arguments: input.arguments,
|
|
|
|
|
messageID: result.info.id,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
return {
|
|
|
|
|
promptInput: {
|
|
|
|
|
sessionID: input.sessionID,
|
|
|
|
|
messageID: input.messageID,
|
|
|
|
|
model: userModel,
|
|
|
|
|
agent: userAgent,
|
|
|
|
|
parts,
|
|
|
|
|
variant: input.variant,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensureTitle(input: {
|
|
|
|
|