saeeol 1.2.0 → 1.2.1
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/package.json +14 -14
- package/src/session/compaction-helpers.ts +1 -169
- package/src/session/compaction.ts +1 -712
- package/src/session/core/compaction/compaction-helpers.ts +169 -0
- package/src/session/core/compaction/compaction.ts +712 -0
- package/src/session/core/compaction/overflow.ts +28 -0
- package/src/session/core/instruction.ts +234 -0
- package/src/session/core/llm.ts +504 -0
- package/src/session/core/network.ts +392 -0
- package/src/session/core/processor.ts +731 -0
- package/src/session/core/projectors.ts +139 -0
- package/src/session/core/resolve-tools.ts +241 -0
- package/src/session/core/retry.ts +149 -0
- package/src/session/core/revert.ts +173 -0
- package/src/session/core/run-state.ts +110 -0
- package/src/session/core/schema.ts +35 -0
- package/src/session/core/session-types.ts +160 -0
- package/src/session/core/session.sql.ts +124 -0
- package/src/session/core/session.ts +948 -0
- package/src/session/core/shell-exec.ts +205 -0
- package/src/session/core/status.ts +100 -0
- package/src/session/core/subtask.ts +268 -0
- package/src/session/core/summary.ts +173 -0
- package/src/session/core/system.ts +114 -0
- package/src/session/core/todo.ts +86 -0
- package/src/session/core/user-part.ts +293 -0
- package/src/session/instruction.ts +1 -234
- package/src/session/llm.ts +1 -504
- package/src/session/message/message-errors.ts +83 -0
- package/src/session/message/message-parts.ts +89 -0
- package/src/session/message/message-query.ts +107 -0
- package/src/session/message/message-transform.ts +156 -0
- package/src/session/message/message-types.ts +68 -0
- package/src/session/message/message-v2.ts +73 -0
- package/src/session/message/message.ts +192 -0
- package/src/session/message-errors.ts +1 -83
- package/src/session/message-parts.ts +1 -89
- package/src/session/message-query.ts +1 -107
- package/src/session/message-transform.ts +1 -156
- package/src/session/message-types.ts +1 -68
- package/src/session/message-v2.ts +1 -73
- package/src/session/message.ts +1 -192
- package/src/session/network.ts +1 -392
- package/src/session/overflow.ts +1 -28
- package/src/session/processor.ts +1 -731
- package/src/session/projectors.ts +2 -139
- package/src/session/prompt/prompt-command.ts +93 -0
- package/src/session/prompt/prompt-loop.ts +299 -0
- package/src/session/prompt/prompt-model.ts +44 -0
- package/src/session/prompt/prompt-reminders.ts +120 -0
- package/src/session/prompt/prompt-resolve.ts +42 -0
- package/src/session/prompt/prompt-schemas.ts +128 -0
- package/src/session/prompt/prompt-title.ts +55 -0
- package/src/session/prompt/prompt-types.ts +47 -0
- package/src/session/prompt/prompt-user-msg.ts +80 -0
- package/src/session/prompt/prompt.ts +211 -0
- package/src/session/prompt-command.ts +1 -93
- package/src/session/prompt-loop.ts +1 -299
- package/src/session/prompt-model.ts +1 -44
- package/src/session/prompt-reminders.ts +1 -120
- package/src/session/prompt-resolve.ts +1 -42
- package/src/session/prompt-schemas.ts +1 -128
- package/src/session/prompt-title.ts +1 -55
- package/src/session/prompt-types.ts +1 -47
- package/src/session/prompt-user-msg.ts +1 -80
- package/src/session/prompt.ts +1 -211
- package/src/session/resolve-tools.ts +1 -241
- package/src/session/retry.ts +1 -149
- package/src/session/revert.ts +1 -173
- package/src/session/run-state.ts +1 -110
- package/src/session/schema.ts +1 -35
- package/src/session/session-types.ts +1 -160
- package/src/session/session.sql.ts +1 -124
- package/src/session/session.ts +1 -948
- package/src/session/shell-exec.ts +1 -205
- package/src/session/status.ts +1 -100
- package/src/session/subtask.ts +1 -268
- package/src/session/summary.ts +1 -173
- package/src/session/system.ts +1 -114
- package/src/session/todo.ts +1 -86
- package/src/session/user-part.ts +1 -293
|
@@ -1,139 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { and } from "drizzle-orm"
|
|
4
|
-
import { SyncEvent } from "@/sync"
|
|
5
|
-
import * as Session from "./session"
|
|
6
|
-
import { MessageV2 } from "./message-v2"
|
|
7
|
-
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
|
8
|
-
import * as Log from "@saeeol/core/util/log"
|
|
9
|
-
|
|
10
|
-
const log = Log.create({ service: "session.projector" })
|
|
11
|
-
|
|
12
|
-
function foreign(err: unknown) {
|
|
13
|
-
if (typeof err !== "object" || err === null) return false
|
|
14
|
-
if ("code" in err && err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") return true
|
|
15
|
-
return "message" in err && typeof err.message === "string" && err.message.includes("FOREIGN KEY constraint failed")
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T
|
|
19
|
-
|
|
20
|
-
function grab<T extends object, K1 extends keyof T, X>(
|
|
21
|
-
obj: T,
|
|
22
|
-
field1: K1,
|
|
23
|
-
cb?: (val: NonNullable<T[K1]>) => X,
|
|
24
|
-
): X | undefined {
|
|
25
|
-
if (obj == undefined || !(field1 in obj)) return undefined
|
|
26
|
-
|
|
27
|
-
const val = obj[field1]
|
|
28
|
-
if (val && typeof val === "object" && cb) {
|
|
29
|
-
return cb(val)
|
|
30
|
-
}
|
|
31
|
-
if (val === undefined) {
|
|
32
|
-
throw new Error(
|
|
33
|
-
"Session update failure: pass `null` to clear a field instead of `undefined`: " + JSON.stringify(obj),
|
|
34
|
-
)
|
|
35
|
-
}
|
|
36
|
-
return val as X | undefined
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function toPartialRow(info: DeepPartial<Session.Info>) {
|
|
40
|
-
const obj = {
|
|
41
|
-
id: grab(info, "id"),
|
|
42
|
-
project_id: grab(info, "projectID"),
|
|
43
|
-
workspace_id: grab(info, "workspaceID"),
|
|
44
|
-
parent_id: grab(info, "parentID"),
|
|
45
|
-
slug: grab(info, "slug"),
|
|
46
|
-
directory: grab(info, "directory"),
|
|
47
|
-
path: grab(info, "path"),
|
|
48
|
-
title: grab(info, "title"),
|
|
49
|
-
version: grab(info, "version"),
|
|
50
|
-
share_url: grab(info, "share", (v) => grab(v, "url")),
|
|
51
|
-
summary_additions: grab(info, "summary", (v) => grab(v, "additions")),
|
|
52
|
-
summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")),
|
|
53
|
-
summary_files: grab(info, "summary", (v) => grab(v, "files")),
|
|
54
|
-
summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")),
|
|
55
|
-
revert: grab(info, "revert"),
|
|
56
|
-
permission: grab(info, "permission"),
|
|
57
|
-
time_created: grab(info, "time", (v) => grab(v, "created")),
|
|
58
|
-
time_updated: grab(info, "time", (v) => grab(v, "updated")),
|
|
59
|
-
time_compacting: grab(info, "time", (v) => grab(v, "compacting")),
|
|
60
|
-
time_archived: grab(info, "time", (v) => grab(v, "archived")),
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return Object.fromEntries(Object.entries(obj).filter(([_, val]) => val !== undefined))
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export default [
|
|
67
|
-
SyncEvent.project(Session.Event.Created, (db, data) => {
|
|
68
|
-
db.insert(SessionTable)
|
|
69
|
-
.values(Session.toRow(data.info as Session.Info))
|
|
70
|
-
.run()
|
|
71
|
-
}),
|
|
72
|
-
|
|
73
|
-
SyncEvent.project(Session.Event.Updated, (db, data) => {
|
|
74
|
-
const info = data.info
|
|
75
|
-
const row = db
|
|
76
|
-
.update(SessionTable)
|
|
77
|
-
.set(toPartialRow(info as Session.Patch))
|
|
78
|
-
.where(eq(SessionTable.id, data.sessionID))
|
|
79
|
-
.returning()
|
|
80
|
-
.get()
|
|
81
|
-
if (!row) throw new NotFoundError({ message: `Session not found: ${data.sessionID}` })
|
|
82
|
-
}),
|
|
83
|
-
|
|
84
|
-
SyncEvent.project(Session.Event.Deleted, (db, data) => {
|
|
85
|
-
db.delete(SessionTable).where(eq(SessionTable.id, data.sessionID)).run()
|
|
86
|
-
}),
|
|
87
|
-
|
|
88
|
-
SyncEvent.project(MessageV2.Event.Updated, (db, data) => {
|
|
89
|
-
const time_created = data.info.time.created
|
|
90
|
-
const { id, sessionID, ...rest } = data.info
|
|
91
|
-
|
|
92
|
-
try {
|
|
93
|
-
db.insert(MessageTable)
|
|
94
|
-
.values({
|
|
95
|
-
id,
|
|
96
|
-
session_id: sessionID,
|
|
97
|
-
time_created,
|
|
98
|
-
data: rest,
|
|
99
|
-
})
|
|
100
|
-
.onConflictDoUpdate({ target: MessageTable.id, set: { data: rest } })
|
|
101
|
-
.run()
|
|
102
|
-
} catch (err) {
|
|
103
|
-
if (!foreign(err)) throw err
|
|
104
|
-
log.warn("ignored late message update", { messageID: id, sessionID })
|
|
105
|
-
}
|
|
106
|
-
}),
|
|
107
|
-
|
|
108
|
-
SyncEvent.project(MessageV2.Event.Removed, (db, data) => {
|
|
109
|
-
db.delete(MessageTable)
|
|
110
|
-
.where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID)))
|
|
111
|
-
.run()
|
|
112
|
-
}),
|
|
113
|
-
|
|
114
|
-
SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => {
|
|
115
|
-
db.delete(PartTable)
|
|
116
|
-
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
|
|
117
|
-
.run()
|
|
118
|
-
}),
|
|
119
|
-
|
|
120
|
-
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
|
|
121
|
-
const { id, messageID, sessionID, ...rest } = data.part
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
db.insert(PartTable)
|
|
125
|
-
.values({
|
|
126
|
-
id,
|
|
127
|
-
message_id: messageID,
|
|
128
|
-
session_id: sessionID,
|
|
129
|
-
time_created: data.time,
|
|
130
|
-
data: rest,
|
|
131
|
-
})
|
|
132
|
-
.onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
|
|
133
|
-
.run()
|
|
134
|
-
} catch (err) {
|
|
135
|
-
if (!foreign(err)) throw err
|
|
136
|
-
log.warn("ignored late part update", { partID: id, messageID, sessionID })
|
|
137
|
-
}
|
|
138
|
-
}),
|
|
139
|
-
]
|
|
1
|
+
export * from "./core/projectors"
|
|
2
|
+
export { default } from "./core/projectors"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { NamedError } from "@saeeol/core/util/error"
|
|
3
|
+
import * as EffectLogger from "@saeeol/core/effect/logger"
|
|
4
|
+
import { ConfigMarkdown } from "@/config/markdown"
|
|
5
|
+
import { Shell } from "@/shell/shell"
|
|
6
|
+
import { Provider } from "@/provider/provider"
|
|
7
|
+
import { Process } from "@/util/process"
|
|
8
|
+
import { SessionID } from "../core/schema"
|
|
9
|
+
import * as Session from "../core/session"
|
|
10
|
+
import { resolvePromptParts } from "./prompt-resolve"
|
|
11
|
+
import { getModel, lastModel } from "./prompt-model"
|
|
12
|
+
import type { CommandInput } from "./prompt-schemas"
|
|
13
|
+
import type { PromptDeps } from "./prompt-types"
|
|
14
|
+
|
|
15
|
+
const elog = EffectLogger.create({ service: "session.prompt" })
|
|
16
|
+
|
|
17
|
+
export const commandHandler = (deps: PromptDeps, markReviewTelemetry?: (parts: any, cmd: string) => void) => {
|
|
18
|
+
const resolveParts = resolvePromptParts(deps)
|
|
19
|
+
|
|
20
|
+
return Effect.fn("SessionPrompt.command")(function* (input: CommandInput) {
|
|
21
|
+
yield* elog.info("command", { sessionID: input.sessionID, command: input.command, agent: input.agent })
|
|
22
|
+
const cmd = yield* deps.commands.get(input.command)
|
|
23
|
+
if (!cmd) {
|
|
24
|
+
const available = (yield* deps.commands.list()).map((c: any) => c.name)
|
|
25
|
+
const hint = available.length ? ` Available commands: ${available.join(", ")}` : ""
|
|
26
|
+
const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` })
|
|
27
|
+
yield* deps.bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
|
|
28
|
+
throw error
|
|
29
|
+
}
|
|
30
|
+
const argsRegex = /\S+|"[^"]*"|'[^']*'/g
|
|
31
|
+
const quoteTrimRegex = /^["']|["']$/g
|
|
32
|
+
const placeholderRegex = /\$(\d+)/g
|
|
33
|
+
const agentName = cmd.agent ?? input.agent ?? (yield* deps.agents.defaultAgent())
|
|
34
|
+
const raw = input.arguments.match(argsRegex) ?? []
|
|
35
|
+
const args = raw.map((arg: string) => arg.replace(quoteTrimRegex, ""))
|
|
36
|
+
const templateCommand = yield* Effect.promise(async () => cmd.template)
|
|
37
|
+
const placeholders = templateCommand.match(placeholderRegex) ?? []
|
|
38
|
+
let last = 0
|
|
39
|
+
for (const item of placeholders) { const value = Number(item.slice(1)); if (value > last) last = value }
|
|
40
|
+
const withArgs = templateCommand.replaceAll(placeholderRegex, (_: string, index: string) => {
|
|
41
|
+
const position = Number(index)
|
|
42
|
+
const argIndex = position - 1
|
|
43
|
+
if (argIndex >= args.length) return ""
|
|
44
|
+
if (position === last) return args.slice(argIndex).join(" ")
|
|
45
|
+
return args[argIndex]
|
|
46
|
+
})
|
|
47
|
+
const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS")
|
|
48
|
+
let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)
|
|
49
|
+
if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
|
|
50
|
+
template = template + "\n\n" + input.arguments
|
|
51
|
+
}
|
|
52
|
+
const bashRegex = /```bash\n([\s\S]*?)```/g
|
|
53
|
+
const shellMatches = ConfigMarkdown.shell(template)
|
|
54
|
+
if (shellMatches.length > 0) {
|
|
55
|
+
const cfg = yield* deps.config.get()
|
|
56
|
+
const sh = Shell.preferred(cfg.shell)
|
|
57
|
+
const results = yield* Effect.promise(() =>
|
|
58
|
+
Promise.all(shellMatches.map(async (match: RegExpExecArray) => (await Process.text([match[1]], { shell: sh, nothrow: true })).text)),
|
|
59
|
+
)
|
|
60
|
+
let index = 0
|
|
61
|
+
template = template.replace(bashRegex, () => results[index++])
|
|
62
|
+
}
|
|
63
|
+
template = template.trim()
|
|
64
|
+
const taskModel = yield* Effect.gen(function* () {
|
|
65
|
+
if (cmd.model) return Provider.parseModel(cmd.model)
|
|
66
|
+
if (cmd.agent) { const cmdAgent = yield* deps.agents.get(cmd.agent); if (cmdAgent?.model) return cmdAgent.model }
|
|
67
|
+
if (input.model) return Provider.parseModel(input.model)
|
|
68
|
+
return yield* lastModel(deps)(input.sessionID)
|
|
69
|
+
})
|
|
70
|
+
yield* getModel(deps)(taskModel.providerID, taskModel.modelID, input.sessionID)
|
|
71
|
+
const agent = yield* deps.agents.get(agentName)
|
|
72
|
+
if (!agent) {
|
|
73
|
+
const available = (yield* deps.agents.list()).filter((a: any) => !a.hidden).map((a: any) => a.name)
|
|
74
|
+
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
|
75
|
+
const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
|
|
76
|
+
yield* deps.bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
|
|
77
|
+
throw error
|
|
78
|
+
}
|
|
79
|
+
const templateParts = yield* resolveParts(template)
|
|
80
|
+
markReviewTelemetry?.(templateParts, input.command)
|
|
81
|
+
const isSubtask = (agent.mode === "subagent" && cmd.subtask !== false) || cmd.subtask === true
|
|
82
|
+
const parts = isSubtask
|
|
83
|
+
? [{ type: "subtask" as const, agent: agent.name, description: cmd.description ?? "",
|
|
84
|
+
command: input.command, model: { providerID: taskModel.providerID, modelID: taskModel.modelID },
|
|
85
|
+
prompt: (templateParts.find((y: any) => y.type === "text") as any)?.text ?? "" }]
|
|
86
|
+
: [...templateParts, ...(input.parts ?? [])]
|
|
87
|
+
const userAgent = isSubtask ? (input.agent ?? (yield* deps.agents.defaultAgent())) : agentName
|
|
88
|
+
const userModel = isSubtask ? input.model ? Provider.parseModel(input.model) : yield* lastModel(deps)(input.sessionID) : taskModel
|
|
89
|
+
yield* deps.plugin.trigger("command.execute.before",
|
|
90
|
+
{ command: input.command, sessionID: input.sessionID, arguments: input.arguments }, { parts })
|
|
91
|
+
return { sessionID: input.sessionID, messageID: input.messageID, model: userModel, agent: userAgent, parts, variant: input.variant, command: input.command, arguments: input.arguments }
|
|
92
|
+
})
|
|
93
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { Effect, Latch, Option } from "effect"
|
|
2
|
+
import * as EffectLogger from "@saeeol/core/effect/logger"
|
|
3
|
+
import * as Log from "@saeeol/core/util/log"
|
|
4
|
+
import { SessionID, MessageID } from "../core/schema"
|
|
5
|
+
import { MessageV2 } from "../message/message-v2"
|
|
6
|
+
import * as Session from "../core/session"
|
|
7
|
+
import { SaeeolSession } from "@/saeeol/session"
|
|
8
|
+
import { SaeeolSessionPrompt } from "@/saeeol/session/prompt"
|
|
9
|
+
import { SaeeolSessionPromptQueue } from "@/saeeol/session/prompt-queue"
|
|
10
|
+
import { SaeeolSessionProcessor } from "@/saeeol/session/processor"
|
|
11
|
+
import { InstanceState } from "@/effect/instance-state"
|
|
12
|
+
import { EffectBridge } from "@/effect/bridge"
|
|
13
|
+
import { type TaskPromptOps } from "@/tool/task"
|
|
14
|
+
import { NamedError } from "@saeeol/core/util/error"
|
|
15
|
+
import MAX_STEPS from "./max-steps.txt"
|
|
16
|
+
import { type PromptInput, type ShellInput, LoopInput, STRUCTURED_OUTPUT_SYSTEM_PROMPT, REQUEST_PRUNE_BYTES, createStructuredOutputTool } from "./prompt-schemas"
|
|
17
|
+
import type { PromptDeps } from "./prompt-types"
|
|
18
|
+
const elog = EffectLogger.create({ service: "session.prompt" })
|
|
19
|
+
const log = Log.create({ service: "session.prompt" })
|
|
20
|
+
|
|
21
|
+
export interface LoopHelpers {
|
|
22
|
+
getModel: (...args: any[]) => any
|
|
23
|
+
lastModel: (...args: any[]) => any
|
|
24
|
+
lastAssistant: (...args: any[]) => any
|
|
25
|
+
createUserMessage: (...args: any[]) => any
|
|
26
|
+
ensureTitle: (...args: any[]) => any
|
|
27
|
+
insertReminders: (...args: any[]) => any
|
|
28
|
+
resolvePromptParts: (...args: any[]) => any
|
|
29
|
+
commandHandler: (...args: any[]) => any
|
|
30
|
+
prompt: (...args: any[]) => any
|
|
31
|
+
cancel: (...args: any[]) => any
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createRunLoop(deps: PromptDeps, helpers: LoopHelpers) {
|
|
35
|
+
const closeReasons = new Map<string, any>()
|
|
36
|
+
const resolveTools = (input: any) => deps.resolveToolsSvc.resolve(input)
|
|
37
|
+
const runner = Effect.fn("SessionPrompt.runner")(function* () { return yield* EffectBridge.make() })
|
|
38
|
+
const ops = Effect.fn("SessionPrompt.ops")(function* () {
|
|
39
|
+
const run = yield* runner()
|
|
40
|
+
return {
|
|
41
|
+
cancel: (id: SessionID) => run.fork(helpers.cancel(id)),
|
|
42
|
+
resolvePromptParts: (t: string) => helpers.resolvePromptParts(t),
|
|
43
|
+
prompt: (input: PromptInput) => helpers.prompt(input),
|
|
44
|
+
} satisfies TaskPromptOps
|
|
45
|
+
})
|
|
46
|
+
const handleSubtask = (input: any) => Effect.gen(function* () {
|
|
47
|
+
const ops_ = yield* ops()
|
|
48
|
+
return yield* deps.subtaskSvc.handle({ ...input, getModel: helpers.getModel, promptOps: ops_ })
|
|
49
|
+
})
|
|
50
|
+
const shellImpl = (input: ShellInput, ready?: Latch.Latch) =>
|
|
51
|
+
deps.shellSvc.exec(input, helpers.getModel, helpers.lastModel as any, ready)
|
|
52
|
+
const runLoop = Effect.fn("SessionPrompt.run")(function* (sessionID: SessionID) {
|
|
53
|
+
const envCache: SaeeolSessionPrompt.EnvCache = {}
|
|
54
|
+
closeReasons.delete(sessionID)
|
|
55
|
+
let compactionAttempts = 0
|
|
56
|
+
const ctx = yield* InstanceState.context
|
|
57
|
+
const slog = elog.with({ sessionID })
|
|
58
|
+
let structured: unknown | undefined
|
|
59
|
+
let step = 0
|
|
60
|
+
const session = yield* deps.sessions.get(sessionID)
|
|
61
|
+
while (true) {
|
|
62
|
+
yield* deps.status.set(sessionID, { type: "busy" })
|
|
63
|
+
yield* slog.info("loop", { step })
|
|
64
|
+
let msgs: MessageV2.WithParts[] = yield* MessageV2.filterCompactedEffect(sessionID)
|
|
65
|
+
msgs = SaeeolSessionPromptQueue.scope(sessionID, msgs)
|
|
66
|
+
msgs = SaeeolSessionPrompt.trimBeforeLastSummary(msgs)
|
|
67
|
+
let lastUser: MessageV2.User | undefined, lastAssistant: MessageV2.Assistant | undefined, lastFinished: MessageV2.Assistant | undefined
|
|
68
|
+
let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
|
|
69
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
70
|
+
const msg = msgs[i]
|
|
71
|
+
if (!lastUser && msg.info.role === "user") lastUser = msg.info
|
|
72
|
+
if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info
|
|
73
|
+
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info
|
|
74
|
+
if (lastUser && lastFinished) break
|
|
75
|
+
const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
|
|
76
|
+
if (task && !lastFinished) tasks.push(...task)
|
|
77
|
+
}
|
|
78
|
+
if (!lastUser) throw new Error("No user message found in stream.")
|
|
79
|
+
const telemetry = SaeeolSessionProcessor.extractReviewTelemetry(
|
|
80
|
+
msgs.findLast((m) => m.info.role === "user" && m.info.id === lastUser.id)?.parts ?? [],
|
|
81
|
+
)
|
|
82
|
+
const lastAssistantMsg = msgs.findLast(
|
|
83
|
+
(msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
|
|
84
|
+
)
|
|
85
|
+
const hasToolCalls = lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false
|
|
86
|
+
if (lastAssistant?.finish && hasToolCalls && lastAssistant.parentID === lastUser.id &&
|
|
87
|
+
lastUser.id < lastAssistant.id &&
|
|
88
|
+
SaeeolSessionPrompt.shouldAskPlanFollowup({ messages: msgs, abort: AbortSignal.any([]) })
|
|
89
|
+
) {
|
|
90
|
+
const action = yield* Effect.promise((signal) =>
|
|
91
|
+
SaeeolSessionPrompt.askPlanFollowup({ sessionID, messages: msgs, abort: signal }),
|
|
92
|
+
)
|
|
93
|
+
if (action === "continue") continue
|
|
94
|
+
yield* slog.info("exiting loop")
|
|
95
|
+
break
|
|
96
|
+
}
|
|
97
|
+
if (lastAssistant?.finish && !["tool-calls"].includes(lastAssistant.finish) &&
|
|
98
|
+
!hasToolCalls && lastAssistant.parentID === lastUser.id && lastUser.id < lastAssistant.id
|
|
99
|
+
) {
|
|
100
|
+
const action = yield* Effect.promise((signal) =>
|
|
101
|
+
SaeeolSessionPrompt.askPlanFollowup({ sessionID, messages: msgs, abort: signal }),
|
|
102
|
+
)
|
|
103
|
+
if (action === "continue") continue
|
|
104
|
+
yield* slog.info("exiting loop")
|
|
105
|
+
break
|
|
106
|
+
}
|
|
107
|
+
step++
|
|
108
|
+
if (step === 1)
|
|
109
|
+
yield* helpers.ensureTitle({
|
|
110
|
+
session, modelID: lastUser.model.modelID, providerID: lastUser.model.providerID, history: msgs,
|
|
111
|
+
}).pipe(Effect.ignore, Effect.forkIn(deps.scope))
|
|
112
|
+
const model = yield* helpers.getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID)
|
|
113
|
+
const task = tasks.pop()
|
|
114
|
+
if (task?.type === "subtask") {
|
|
115
|
+
yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs })
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
if (task?.type === "compaction") {
|
|
119
|
+
const result = yield* deps.compaction.process({
|
|
120
|
+
messages: msgs, parentID: lastUser.id, sessionID, auto: task.auto, overflow: task.overflow,
|
|
121
|
+
})
|
|
122
|
+
if (result === "stop") { closeReasons.set(sessionID, "error"); break }
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (lastFinished && lastFinished.summary !== true &&
|
|
126
|
+
(yield* deps.compaction.isOverflow({ tokens: lastFinished.tokens, model }))
|
|
127
|
+
) {
|
|
128
|
+
const guard = SaeeolSessionPrompt.guardCompactionAttempt({
|
|
129
|
+
sessionID, attempts: compactionAttempts, closeReasons, message: lastFinished,
|
|
130
|
+
})
|
|
131
|
+
if (guard.exhausted) {
|
|
132
|
+
yield* deps.sessions.updateMessage(lastFinished)
|
|
133
|
+
yield* deps.bus.publish(Session.Event.Error, { sessionID, error: guard.error })
|
|
134
|
+
break
|
|
135
|
+
}
|
|
136
|
+
compactionAttempts++
|
|
137
|
+
yield* deps.compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
const agent = yield* deps.agents.get(lastUser.agent)
|
|
141
|
+
if (!agent) {
|
|
142
|
+
const available = (yield* deps.agents.list()).filter((a: any) => !a.hidden).map((a: any) => a.name)
|
|
143
|
+
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
|
144
|
+
const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })
|
|
145
|
+
yield* deps.bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })
|
|
146
|
+
throw error
|
|
147
|
+
}
|
|
148
|
+
const maxSteps = agent.steps ?? Infinity
|
|
149
|
+
const isLastStep = step >= maxSteps
|
|
150
|
+
msgs = yield* helpers.insertReminders({ messages: msgs, agent, session })
|
|
151
|
+
const msg: MessageV2.Assistant = {
|
|
152
|
+
id: MessageID.ascending(), parentID: lastUser.id, role: "assistant", mode: agent.name,
|
|
153
|
+
agent: agent.name, variant: lastUser.model.variant, path: { cwd: ctx.directory, root: ctx.worktree },
|
|
154
|
+
cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
155
|
+
modelID: model.id, providerID: model.providerID, time: { created: Date.now() }, sessionID,
|
|
156
|
+
}
|
|
157
|
+
yield* deps.sessions.updateMessage(msg)
|
|
158
|
+
const handle = yield* deps.processor.create({ assistantMessage: msg, sessionID, model, telemetry })
|
|
159
|
+
const outcome: "break" | "continue" = yield* Effect.gen(function* () {
|
|
160
|
+
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
|
|
161
|
+
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
|
162
|
+
const tools = yield* resolveTools({
|
|
163
|
+
agent, session, model, tools: lastUser.tools, processor: handle,
|
|
164
|
+
bypassAgentCheck, messages: msgs,
|
|
165
|
+
})
|
|
166
|
+
if (lastUser.format?.type === "json_schema") {
|
|
167
|
+
tools["StructuredOutput"] = createStructuredOutputTool({
|
|
168
|
+
schema: lastUser.format.schema,
|
|
169
|
+
onSuccess(output: unknown) { structured = output },
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
if (step === 1)
|
|
173
|
+
yield* deps.summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(deps.scope))
|
|
174
|
+
if (step > 1 && lastFinished) {
|
|
175
|
+
for (const m of msgs) {
|
|
176
|
+
if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue
|
|
177
|
+
for (const p of m.parts) {
|
|
178
|
+
if (p.type !== "text" || p.ignored || p.synthetic || !p.text.trim()) continue
|
|
179
|
+
p.text = ["<system-reminder>", "The user sent the following message:", p.text, "",
|
|
180
|
+
"Please address this message and continue with your tasks.", "</system-reminder>"].join("\n")
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
yield* deps.plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
|
185
|
+
SaeeolSessionPrompt.injectEditorContext({ msgs, lastUser, sessionID, cache: envCache })
|
|
186
|
+
msgs = SaeeolSessionPrompt.maybeStripHistoricalMedia(msgs)
|
|
187
|
+
const allResult: any = yield* Effect.all([
|
|
188
|
+
deps.sys.skills(agent), deps.sys.environment(model, lastUser.editorContext),
|
|
189
|
+
deps.instruction.system().pipe(Effect.orDie),
|
|
190
|
+
])
|
|
191
|
+
const skills: any = allResult[0], env: any[] = allResult[1], instructions: any[] = allResult[2]
|
|
192
|
+
let modelMsgs = yield* MessageV2.toModelMessagesEffect(msgs, model)
|
|
193
|
+
const size = Buffer.byteLength(JSON.stringify(modelMsgs))
|
|
194
|
+
if (size > REQUEST_PRUNE_BYTES) {
|
|
195
|
+
yield* deps.compaction.prune({ sessionID, reason: "payload-limit" })
|
|
196
|
+
msgs = yield* MessageV2.filterCompactedEffect(sessionID)
|
|
197
|
+
msgs = SaeeolSessionPromptQueue.scope(sessionID, msgs)
|
|
198
|
+
msgs = SaeeolSessionPrompt.trimBeforeLastSummary(msgs)
|
|
199
|
+
yield* deps.plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
|
200
|
+
SaeeolSessionPrompt.injectEditorContext({ msgs, lastUser, sessionID, cache: envCache })
|
|
201
|
+
msgs = SaeeolSessionPrompt.maybeStripHistoricalMedia(msgs)
|
|
202
|
+
modelMsgs = yield* MessageV2.toModelMessagesEffect(msgs, model)
|
|
203
|
+
const nextSize = Buffer.byteLength(JSON.stringify(modelMsgs))
|
|
204
|
+
if (nextSize > REQUEST_PRUNE_BYTES) log.warn("payload still large after pruning", { size: nextSize })
|
|
205
|
+
}
|
|
206
|
+
const system = [...env, ...instructions, ...(skills ? [skills] : [])]
|
|
207
|
+
const format = lastUser.format ?? { type: "text" as const }
|
|
208
|
+
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
|
|
209
|
+
const result = yield* handle.process({
|
|
210
|
+
user: lastUser, agent, permission: SaeeolSessionPrompt.guardPermissions({ agent, session }),
|
|
211
|
+
sessionID, parentSessionID: session.parentID, system,
|
|
212
|
+
messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],
|
|
213
|
+
tools, model, toolChoice: format.type === "json_schema" ? "required" : undefined,
|
|
214
|
+
})
|
|
215
|
+
if (structured !== undefined) {
|
|
216
|
+
handle.message.structured = structured
|
|
217
|
+
handle.message.finish = handle.message.finish ?? "stop"
|
|
218
|
+
yield* deps.sessions.updateMessage(handle.message)
|
|
219
|
+
return "break" as const
|
|
220
|
+
}
|
|
221
|
+
const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)
|
|
222
|
+
if (finished && !handle.message.error) {
|
|
223
|
+
if (format.type === "json_schema") {
|
|
224
|
+
handle.message.error = new MessageV2.StructuredOutputError({
|
|
225
|
+
message: "Model did not produce structured output", retries: 0,
|
|
226
|
+
}).toObject()
|
|
227
|
+
yield* deps.sessions.updateMessage(handle.message)
|
|
228
|
+
return "break" as const
|
|
229
|
+
}
|
|
230
|
+
if (handle.message.finish === "error") {
|
|
231
|
+
SaeeolSessionProcessor.providerFinishError(handle.message)
|
|
232
|
+
yield* deps.sessions.updateMessage(handle.message)
|
|
233
|
+
closeReasons.set(sessionID, "error")
|
|
234
|
+
return "break" as const
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (result === "stop") {
|
|
238
|
+
if (handle.message.error) closeReasons.set(sessionID, "error")
|
|
239
|
+
return "break" as const
|
|
240
|
+
}
|
|
241
|
+
if (result === "compact") {
|
|
242
|
+
const guard = SaeeolSessionPrompt.guardCompactionAttempt({
|
|
243
|
+
sessionID, attempts: compactionAttempts, closeReasons, message: handle.message,
|
|
244
|
+
})
|
|
245
|
+
if (guard.exhausted) {
|
|
246
|
+
yield* deps.sessions.updateMessage(handle.message)
|
|
247
|
+
yield* deps.bus.publish(Session.Event.Error, { sessionID, error: guard.error })
|
|
248
|
+
return "break" as const
|
|
249
|
+
}
|
|
250
|
+
compactionAttempts++
|
|
251
|
+
yield* deps.compaction.create({
|
|
252
|
+
sessionID, agent: lastUser.agent, model: lastUser.model, auto: true,
|
|
253
|
+
overflow: !handle.message.finish,
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
if (SaeeolSessionPromptQueue.hasFollowup(sessionID)) {
|
|
257
|
+
closeReasons.set(sessionID, "interrupted")
|
|
258
|
+
return "break" as const
|
|
259
|
+
}
|
|
260
|
+
if (result !== "compact" && !handle.message.finish) {
|
|
261
|
+
handle.message.finish = "unknown"
|
|
262
|
+
yield* deps.sessions.updateMessage(handle.message)
|
|
263
|
+
}
|
|
264
|
+
return "continue" as const
|
|
265
|
+
}).pipe(Effect.ensuring(deps.instruction.clear(handle.message.id)))
|
|
266
|
+
if (outcome === "break") break
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
yield* deps.compaction.prune({ sessionID, reason: "normal" }).pipe(Effect.ignore, Effect.forkIn(deps.scope))
|
|
270
|
+
return yield* helpers.lastAssistant(sessionID)
|
|
271
|
+
})
|
|
272
|
+
const loop = Effect.fn("SessionPrompt.loop")(function* (input: LoopInput) {
|
|
273
|
+
yield* SaeeolSessionPrompt.recoverDanglingAssistant({
|
|
274
|
+
sessionID: input.sessionID, status: deps.status, sessions: deps.sessions,
|
|
275
|
+
})
|
|
276
|
+
yield* SaeeolSessionPrompt.recoverProviderFinishError({
|
|
277
|
+
sessionID: input.sessionID, status: deps.status, sessions: deps.sessions,
|
|
278
|
+
})
|
|
279
|
+
yield* deps.bus.publish(SaeeolSession.Event.TurnOpen, { sessionID: input.sessionID })
|
|
280
|
+
return yield* Effect.onExit(
|
|
281
|
+
deps.state.ensureRunning(input.sessionID, helpers.lastAssistant(input.sessionID), runLoop(input.sessionID)),
|
|
282
|
+
Effect.fnUntraced(function* (exit) {
|
|
283
|
+
yield* deps.bus.publish(SaeeolSession.Event.TurnClose, {
|
|
284
|
+
sessionID: input.sessionID,
|
|
285
|
+
reason: SaeeolSessionPrompt.resolveCloseReason({
|
|
286
|
+
sessionID: input.sessionID, closeReasons, exit,
|
|
287
|
+
}),
|
|
288
|
+
})
|
|
289
|
+
}),
|
|
290
|
+
)
|
|
291
|
+
})
|
|
292
|
+
const shell = Effect.fn("SessionPrompt.shell")(function* (input: ShellInput) {
|
|
293
|
+
const ready = yield* Latch.make()
|
|
294
|
+
return yield* deps.state.startShell(
|
|
295
|
+
input.sessionID, helpers.lastAssistant(input.sessionID), shellImpl(input, ready), ready,
|
|
296
|
+
)
|
|
297
|
+
})
|
|
298
|
+
return { runLoop, loop, shell, closeReasons }
|
|
299
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Effect, Exit, Cause, Option, Types } from "effect"
|
|
2
|
+
import { SessionID, MessageID, PartID } from "../core/schema"
|
|
3
|
+
import { MessageV2 } from "../message/message-v2"
|
|
4
|
+
import { NamedError } from "@saeeol/core/util/error"
|
|
5
|
+
import * as Log from "@saeeol/core/util/log"
|
|
6
|
+
import type { PromptInput } from "./prompt-schemas"
|
|
7
|
+
import type { ModelID, ProviderID } from "../../provider/schema"
|
|
8
|
+
import type { PromptDeps } from "./prompt-types"
|
|
9
|
+
|
|
10
|
+
const log = Log.create({ service: "session.prompt" })
|
|
11
|
+
|
|
12
|
+
export const getModel = (deps: PromptDeps) =>
|
|
13
|
+
Effect.fn("SessionPrompt.getModel")(function* (providerID: ProviderID, modelID: ModelID, sessionID: SessionID) {
|
|
14
|
+
const exit = yield* deps.provider.getModel(providerID, modelID).pipe(Effect.exit)
|
|
15
|
+
if (Exit.isSuccess(exit)) return exit.value
|
|
16
|
+
const err = Cause.squash(exit.cause)
|
|
17
|
+
if (deps.provider.ModelNotFoundError?.isInstance?.(err) ?? (err as any).constructor?.name === "ModelNotFoundError") {
|
|
18
|
+
const hint = (err as any).data?.suggestions?.length ? ` Did you mean: ${(err as any).data.suggestions.join(", ")}?` : ""
|
|
19
|
+
yield* deps.bus.publish(deps.sessions.constructor?.Event?.Error ?? { type: "session.error" }, {
|
|
20
|
+
sessionID,
|
|
21
|
+
error: new NamedError.Unknown({ message: `Model not found: ${providerID}/${modelID}.${hint}` }).toObject(),
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
return yield* Effect.failCause(exit.cause)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const lastModel = (deps: PromptDeps) =>
|
|
28
|
+
Effect.fnUntraced(function* (sessionID: SessionID) {
|
|
29
|
+
const match = yield* deps.sessions.findMessage(sessionID, (m: any) => m.info.role === "user" && !!m.info.model) as any
|
|
30
|
+
if (Option.isSome(match) && (match.value as any).info.role === "user") return (match.value as any).info.model
|
|
31
|
+
return yield* deps.provider.defaultModel()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export const lastAssistant = (deps: PromptDeps) =>
|
|
35
|
+
Effect.fnUntraced(function* (sessionID: SessionID) {
|
|
36
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
37
|
+
const match = yield* deps.sessions.findMessage(sessionID, (m: any) => m.info.role !== "user")
|
|
38
|
+
if (Option.isSome(match)) return match.value
|
|
39
|
+
const msgs = yield* deps.sessions.messages({ sessionID, limit: 1 })
|
|
40
|
+
if (msgs.length > 0) return msgs[0]
|
|
41
|
+
yield* Effect.sleep("50 millis")
|
|
42
|
+
}
|
|
43
|
+
throw new Error("Impossible")
|
|
44
|
+
})
|