mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-02 18:57:08 +00:00
effectify ensureTitle: move into layer, use agents/sessions services, remove dead code
This commit is contained in:
@@ -43,7 +43,6 @@ import { Tool } from "@/tool/tool"
|
|||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { SessionStatus } from "./status"
|
import { SessionStatus } from "./status"
|
||||||
import { LLM } from "./llm"
|
import { LLM } from "./llm"
|
||||||
import { iife } from "@/util/iife"
|
|
||||||
import { Shell } from "@/shell/shell"
|
import { Shell } from "@/shell/shell"
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
@@ -105,10 +104,7 @@ export namespace SessionPrompt {
|
|||||||
const loops = new Map<string, LoopEntry>()
|
const loops = new Map<string, LoopEntry>()
|
||||||
const shells = new Map<string, Fiber.Fiber<MessageV2.WithParts, unknown>>()
|
const shells = new Map<string, Fiber.Fiber<MessageV2.WithParts, unknown>>()
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
Fiber.interruptAll([
|
Fiber.interruptAll([...loops.values().flatMap((e) => (e.fiber ? [e.fiber] : [])), ...shells.values()]),
|
||||||
...loops.values().flatMap((e) => e.fiber ? [e.fiber] : []),
|
|
||||||
...shells.values(),
|
|
||||||
]),
|
|
||||||
)
|
)
|
||||||
return { loops, shells }
|
return { loops, shells }
|
||||||
}),
|
}),
|
||||||
@@ -174,6 +170,68 @@ export namespace SessionPrompt {
|
|||||||
return parts
|
return parts
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: {
|
||||||
|
session: Session.Info
|
||||||
|
history: MessageV2.WithParts[]
|
||||||
|
providerID: ProviderID
|
||||||
|
modelID: ModelID
|
||||||
|
}) {
|
||||||
|
if (input.session.parentID) return
|
||||||
|
if (!Session.isDefaultTitle(input.session.title)) return
|
||||||
|
|
||||||
|
const firstRealUserIdx = input.history.findIndex(
|
||||||
|
(m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic),
|
||||||
|
)
|
||||||
|
if (firstRealUserIdx === -1) return
|
||||||
|
|
||||||
|
const isFirst =
|
||||||
|
input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic))
|
||||||
|
.length === 1
|
||||||
|
if (!isFirst) return
|
||||||
|
|
||||||
|
const context = input.history.slice(0, firstRealUserIdx + 1)
|
||||||
|
const firstUser = context[firstRealUserIdx]
|
||||||
|
if (!firstUser || firstUser.info.role !== "user") return
|
||||||
|
|
||||||
|
const subtasks = firstUser.parts.filter((p) => p.type === "subtask") as MessageV2.SubtaskPart[]
|
||||||
|
const onlySubtasks = subtasks.length > 0 && firstUser.parts.every((p) => p.type === "subtask")
|
||||||
|
|
||||||
|
const ag = yield* agents.get("title")
|
||||||
|
if (!ag) return
|
||||||
|
const mdl = yield* Effect.promise(async () => {
|
||||||
|
if (ag.model) return Provider.getModel(ag.model.providerID, ag.model.modelID)
|
||||||
|
return (await Provider.getSmallModel(input.providerID)) ?? Provider.getModel(input.providerID, input.modelID)
|
||||||
|
})
|
||||||
|
const result = yield* Effect.promise(async () => {
|
||||||
|
const msgs = onlySubtasks
|
||||||
|
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
|
||||||
|
: await MessageV2.toModelMessages(context, mdl)
|
||||||
|
return LLM.stream({
|
||||||
|
agent: ag,
|
||||||
|
user: firstUser.info as MessageV2.User,
|
||||||
|
system: [],
|
||||||
|
small: true,
|
||||||
|
tools: {},
|
||||||
|
model: mdl,
|
||||||
|
abort: new AbortController().signal,
|
||||||
|
sessionID: input.session.id,
|
||||||
|
retries: 2,
|
||||||
|
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const text = yield* Effect.promise(() => result.text)
|
||||||
|
const cleaned = text
|
||||||
|
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.length > 0)
|
||||||
|
if (!cleaned) return
|
||||||
|
const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
|
||||||
|
yield* sessions.setTitle({ sessionID: input.session.id, title: t }).pipe(
|
||||||
|
Effect.catchCause(() => Effect.void),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) {
|
const prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) {
|
||||||
const session = yield* sessions.get(input.sessionID)
|
const session = yield* sessions.get(input.sessionID)
|
||||||
yield* Effect.promise(() => SessionRevert.cleanup(session))
|
yield* Effect.promise(() => SessionRevert.cleanup(session))
|
||||||
@@ -240,14 +298,12 @@ export namespace SessionPrompt {
|
|||||||
|
|
||||||
step++
|
step++
|
||||||
if (step === 1)
|
if (step === 1)
|
||||||
yield* Effect.promise(() =>
|
yield* title({
|
||||||
ensureTitle({
|
|
||||||
session,
|
session,
|
||||||
modelID: lastUser.model.modelID,
|
modelID: lastUser.model.modelID,
|
||||||
providerID: lastUser.model.providerID,
|
providerID: lastUser.model.providerID,
|
||||||
history: msgs,
|
history: msgs,
|
||||||
}),
|
}).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||||
).pipe(Effect.ignore, Effect.forkIn(scope))
|
|
||||||
|
|
||||||
const model = yield* Effect.promise(() =>
|
const model = yield* Effect.promise(() =>
|
||||||
Provider.getModel(lastUser!.model.providerID, lastUser!.model.modelID).catch((e) => {
|
Provider.getModel(lastUser!.model.providerID, lastUser!.model.modelID).catch((e) => {
|
||||||
@@ -337,7 +393,15 @@ export namespace SessionPrompt {
|
|||||||
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
||||||
|
|
||||||
const tools = yield* Effect.promise(() =>
|
const tools = yield* Effect.promise(() =>
|
||||||
resolveTools({ agent, session, model, tools: lastUser!.tools, processor: handle, bypassAgentCheck, messages: msgs }),
|
resolveTools({
|
||||||
|
agent,
|
||||||
|
session,
|
||||||
|
model,
|
||||||
|
tools: lastUser!.tools,
|
||||||
|
processor: handle,
|
||||||
|
bypassAgentCheck,
|
||||||
|
messages: msgs,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (lastUser!.format?.type === "json_schema") {
|
if (lastUser!.format?.type === "json_schema") {
|
||||||
@@ -389,10 +453,7 @@ export namespace SessionPrompt {
|
|||||||
abort: ctrl.signal,
|
abort: ctrl.signal,
|
||||||
sessionID,
|
sessionID,
|
||||||
system,
|
system,
|
||||||
messages: [
|
messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],
|
||||||
...modelMsgs,
|
|
||||||
...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : []),
|
|
||||||
],
|
|
||||||
tools,
|
tools,
|
||||||
model,
|
model,
|
||||||
toolChoice: format.type === "json_schema" ? "required" : undefined,
|
toolChoice: format.type === "json_schema" ? "required" : undefined,
|
||||||
@@ -461,7 +522,8 @@ export namespace SessionPrompt {
|
|||||||
const entry = s.loops.get(sessionID)
|
const entry = s.loops.get(sessionID)
|
||||||
if (entry) {
|
if (entry) {
|
||||||
// On interrupt, resolve queued callers with the last assistant message
|
// On interrupt, resolve queued callers with the last assistant message
|
||||||
const resolved = Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
|
const resolved =
|
||||||
|
Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
|
||||||
? Exit.succeed(yield* lastAssistant(sessionID))
|
? Exit.succeed(yield* lastAssistant(sessionID))
|
||||||
: exit
|
: exit
|
||||||
for (const d of entry.queue) yield* Deferred.done(d, resolved)
|
for (const d of entry.queue) yield* Deferred.done(d, resolved)
|
||||||
@@ -568,7 +630,9 @@ export namespace SessionPrompt {
|
|||||||
if (shellMatches.length > 0) {
|
if (shellMatches.length > 0) {
|
||||||
const sh = Shell.preferred()
|
const sh = Shell.preferred()
|
||||||
const results = yield* Effect.promise(() =>
|
const results = yield* Effect.promise(() =>
|
||||||
Promise.all(shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text)),
|
Promise.all(
|
||||||
|
shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
let index = 0
|
let index = 0
|
||||||
template = template.replace(bashRegex, () => results[index++])
|
template = template.replace(bashRegex, () => results[index++])
|
||||||
@@ -591,7 +655,9 @@ export namespace SessionPrompt {
|
|||||||
const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : ""
|
const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : ""
|
||||||
Bus.publish(Session.Event.Error, {
|
Bus.publish(Session.Event.Error, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
error: new NamedError.Unknown({ message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}` }).toObject(),
|
error: new NamedError.Unknown({
|
||||||
|
message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`,
|
||||||
|
}).toObject(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
throw e
|
throw e
|
||||||
@@ -913,11 +979,7 @@ export namespace SessionPrompt {
|
|||||||
sessionID,
|
sessionID,
|
||||||
messageID: assistantMessage.id,
|
messageID: assistantMessage.id,
|
||||||
}))
|
}))
|
||||||
await Plugin.trigger(
|
await Plugin.trigger("tool.execute.after", { tool: "task", sessionID, callID: part.id, args: taskArgs }, result)
|
||||||
"tool.execute.after",
|
|
||||||
{ tool: "task", sessionID, callID: part.id, args: taskArgs },
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
assistantMessage.finish = "tool-calls"
|
assistantMessage.finish = "tool-calls"
|
||||||
assistantMessage.time.completed = Date.now()
|
assistantMessage.time.completed = Date.now()
|
||||||
await Session.updateMessage(assistantMessage)
|
await Session.updateMessage(assistantMessage)
|
||||||
@@ -1821,9 +1883,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
}
|
}
|
||||||
await Session.updatePart(part)
|
await Session.updatePart(part)
|
||||||
const sh = Shell.preferred()
|
const sh = Shell.preferred()
|
||||||
const shellName = (
|
const shellName = (process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)).toLowerCase()
|
||||||
process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)
|
|
||||||
).toLowerCase()
|
|
||||||
|
|
||||||
const invocations: Record<string, { args: string[] }> = {
|
const invocations: Record<string, { args: string[] }> = {
|
||||||
nu: {
|
nu: {
|
||||||
@@ -1973,80 +2033,4 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
const placeholderRegex = /\$(\d+)/g
|
const placeholderRegex = /\$(\d+)/g
|
||||||
const quoteTrimRegex = /^["']|["']$/g
|
const quoteTrimRegex = /^["']|["']$/g
|
||||||
|
|
||||||
async function ensureTitle(input: {
|
|
||||||
session: Session.Info
|
|
||||||
history: MessageV2.WithParts[]
|
|
||||||
providerID: ProviderID
|
|
||||||
modelID: ModelID
|
|
||||||
}) {
|
|
||||||
if (input.session.parentID) return
|
|
||||||
if (!Session.isDefaultTitle(input.session.title)) return
|
|
||||||
|
|
||||||
// Find first non-synthetic user message
|
|
||||||
const firstRealUserIdx = input.history.findIndex(
|
|
||||||
(m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic),
|
|
||||||
)
|
|
||||||
if (firstRealUserIdx === -1) return
|
|
||||||
|
|
||||||
const isFirst =
|
|
||||||
input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic))
|
|
||||||
.length === 1
|
|
||||||
if (!isFirst) return
|
|
||||||
|
|
||||||
// Gather all messages up to and including the first real user message for context
|
|
||||||
// This includes any shell/subtask executions that preceded the user's first prompt
|
|
||||||
const contextMessages = input.history.slice(0, firstRealUserIdx + 1)
|
|
||||||
const firstRealUser = contextMessages[firstRealUserIdx]
|
|
||||||
|
|
||||||
// For subtask-only messages (from command invocations), extract the prompt directly
|
|
||||||
// since toModelMessage converts subtask parts to generic "The following tool was executed by the user"
|
|
||||||
const subtaskParts = firstRealUser.parts.filter((p) => p.type === "subtask") as MessageV2.SubtaskPart[]
|
|
||||||
const hasOnlySubtaskParts = subtaskParts.length > 0 && firstRealUser.parts.every((p) => p.type === "subtask")
|
|
||||||
|
|
||||||
const agent = await Agent.get("title")
|
|
||||||
if (!agent) return
|
|
||||||
const model = await iife(async () => {
|
|
||||||
if (agent.model) return await Provider.getModel(agent.model.providerID, agent.model.modelID)
|
|
||||||
return (
|
|
||||||
(await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID))
|
|
||||||
)
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
const result = await LLM.stream({
|
|
||||||
agent,
|
|
||||||
user: firstRealUser.info as MessageV2.User,
|
|
||||||
system: [],
|
|
||||||
small: true,
|
|
||||||
tools: {},
|
|
||||||
model,
|
|
||||||
abort: new AbortController().signal,
|
|
||||||
sessionID: input.session.id,
|
|
||||||
retries: 2,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: "Generate a title for this conversation:\n",
|
|
||||||
},
|
|
||||||
...(hasOnlySubtaskParts
|
|
||||||
? [{ role: "user" as const, content: subtaskParts.map((p) => p.prompt).join("\n") }]
|
|
||||||
: await MessageV2.toModelMessages(contextMessages, model)),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
const text = await result.text
|
|
||||||
const cleaned = text
|
|
||||||
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.find((line) => line.length > 0)
|
|
||||||
if (!cleaned) return
|
|
||||||
|
|
||||||
const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
|
|
||||||
return Session.setTitle({ sessionID: input.session.id, title }).catch((err) => {
|
|
||||||
if (NotFoundError.isInstance(err)) return
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
log.error("failed to generate title", { error })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user