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.
Files changed (81) hide show
  1. package/package.json +14 -14
  2. package/src/session/compaction-helpers.ts +1 -169
  3. package/src/session/compaction.ts +1 -712
  4. package/src/session/core/compaction/compaction-helpers.ts +169 -0
  5. package/src/session/core/compaction/compaction.ts +712 -0
  6. package/src/session/core/compaction/overflow.ts +28 -0
  7. package/src/session/core/instruction.ts +234 -0
  8. package/src/session/core/llm.ts +504 -0
  9. package/src/session/core/network.ts +392 -0
  10. package/src/session/core/processor.ts +731 -0
  11. package/src/session/core/projectors.ts +139 -0
  12. package/src/session/core/resolve-tools.ts +241 -0
  13. package/src/session/core/retry.ts +149 -0
  14. package/src/session/core/revert.ts +173 -0
  15. package/src/session/core/run-state.ts +110 -0
  16. package/src/session/core/schema.ts +35 -0
  17. package/src/session/core/session-types.ts +160 -0
  18. package/src/session/core/session.sql.ts +124 -0
  19. package/src/session/core/session.ts +948 -0
  20. package/src/session/core/shell-exec.ts +205 -0
  21. package/src/session/core/status.ts +100 -0
  22. package/src/session/core/subtask.ts +268 -0
  23. package/src/session/core/summary.ts +173 -0
  24. package/src/session/core/system.ts +114 -0
  25. package/src/session/core/todo.ts +86 -0
  26. package/src/session/core/user-part.ts +293 -0
  27. package/src/session/instruction.ts +1 -234
  28. package/src/session/llm.ts +1 -504
  29. package/src/session/message/message-errors.ts +83 -0
  30. package/src/session/message/message-parts.ts +89 -0
  31. package/src/session/message/message-query.ts +107 -0
  32. package/src/session/message/message-transform.ts +156 -0
  33. package/src/session/message/message-types.ts +68 -0
  34. package/src/session/message/message-v2.ts +73 -0
  35. package/src/session/message/message.ts +192 -0
  36. package/src/session/message-errors.ts +1 -83
  37. package/src/session/message-parts.ts +1 -89
  38. package/src/session/message-query.ts +1 -107
  39. package/src/session/message-transform.ts +1 -156
  40. package/src/session/message-types.ts +1 -68
  41. package/src/session/message-v2.ts +1 -73
  42. package/src/session/message.ts +1 -192
  43. package/src/session/network.ts +1 -392
  44. package/src/session/overflow.ts +1 -28
  45. package/src/session/processor.ts +1 -731
  46. package/src/session/projectors.ts +2 -139
  47. package/src/session/prompt/prompt-command.ts +93 -0
  48. package/src/session/prompt/prompt-loop.ts +299 -0
  49. package/src/session/prompt/prompt-model.ts +44 -0
  50. package/src/session/prompt/prompt-reminders.ts +120 -0
  51. package/src/session/prompt/prompt-resolve.ts +42 -0
  52. package/src/session/prompt/prompt-schemas.ts +128 -0
  53. package/src/session/prompt/prompt-title.ts +55 -0
  54. package/src/session/prompt/prompt-types.ts +47 -0
  55. package/src/session/prompt/prompt-user-msg.ts +80 -0
  56. package/src/session/prompt/prompt.ts +211 -0
  57. package/src/session/prompt-command.ts +1 -93
  58. package/src/session/prompt-loop.ts +1 -299
  59. package/src/session/prompt-model.ts +1 -44
  60. package/src/session/prompt-reminders.ts +1 -120
  61. package/src/session/prompt-resolve.ts +1 -42
  62. package/src/session/prompt-schemas.ts +1 -128
  63. package/src/session/prompt-title.ts +1 -55
  64. package/src/session/prompt-types.ts +1 -47
  65. package/src/session/prompt-user-msg.ts +1 -80
  66. package/src/session/prompt.ts +1 -211
  67. package/src/session/resolve-tools.ts +1 -241
  68. package/src/session/retry.ts +1 -149
  69. package/src/session/revert.ts +1 -173
  70. package/src/session/run-state.ts +1 -110
  71. package/src/session/schema.ts +1 -35
  72. package/src/session/session-types.ts +1 -160
  73. package/src/session/session.sql.ts +1 -124
  74. package/src/session/session.ts +1 -948
  75. package/src/session/shell-exec.ts +1 -205
  76. package/src/session/status.ts +1 -100
  77. package/src/session/subtask.ts +1 -268
  78. package/src/session/summary.ts +1 -173
  79. package/src/session/system.ts +1 -114
  80. package/src/session/todo.ts +1 -86
  81. package/src/session/user-part.ts +1 -293
@@ -1,139 +1,2 @@
1
- import { NotFoundError } from "@/storage/storage"
2
- import { eq } from "drizzle-orm"
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
+ })