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
|
@@ -0,0 +1,139 @@
|
|
|
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/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
|
+
]
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
|
|
2
|
+
import { Effect, Context, Layer } from "effect"
|
|
3
|
+
import * as EffectZod from "@/util/effect-zod"
|
|
4
|
+
import { ModelID, ProviderID } from "../../provider/schema"
|
|
5
|
+
import { SessionID, PartID } from "./schema"
|
|
6
|
+
import { MessageV2 } from "../message/message-v2"
|
|
7
|
+
import * as Session from "./session"
|
|
8
|
+
import { Agent } from "../../agent/agent"
|
|
9
|
+
import { Provider } from "@/provider/provider"
|
|
10
|
+
import { ProviderTransform } from "@/provider/transform"
|
|
11
|
+
import { ToolRegistry } from "@/tool/registry"
|
|
12
|
+
import { MCP } from "../../mcp"
|
|
13
|
+
import { Plugin } from "../../plugin"
|
|
14
|
+
import { Permission } from "@/permission"
|
|
15
|
+
import { Truncate } from "@/tool/truncate"
|
|
16
|
+
import { SessionProcessor } from "./processor"
|
|
17
|
+
import { SaeeolSessionPrompt } from "@/saeeol/session/prompt"
|
|
18
|
+
import { EffectBridge } from "@/effect/bridge"
|
|
19
|
+
import { Tool } from "@/tool/tool"
|
|
20
|
+
import { type TaskPromptOps } from "@/tool/task"
|
|
21
|
+
import * as Log from "@saeeol/core/util/log"
|
|
22
|
+
|
|
23
|
+
const log = Log.create({ service: "session.resolve-tools" })
|
|
24
|
+
|
|
25
|
+
export interface ResolveToolsInput {
|
|
26
|
+
agent: Agent.Info
|
|
27
|
+
model: Provider.Model
|
|
28
|
+
session: Session.Info
|
|
29
|
+
tools?: Record<string, boolean>
|
|
30
|
+
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
|
|
31
|
+
bypassAgentCheck: boolean
|
|
32
|
+
messages: MessageV2.WithParts[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Interface {
|
|
36
|
+
readonly resolve: (input: ResolveToolsInput) => Effect.Effect<Record<string, AITool>>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class Service extends Context.Service<Service, Interface>()("@saeeol/SessionResolveTools") {}
|
|
40
|
+
|
|
41
|
+
export const layer = Layer.effect(
|
|
42
|
+
Service,
|
|
43
|
+
Effect.gen(function* () {
|
|
44
|
+
const registry = yield* ToolRegistry.Service
|
|
45
|
+
const plugin = yield* Plugin.Service
|
|
46
|
+
const permission = yield* Permission.Service
|
|
47
|
+
const mcp = yield* MCP.Service
|
|
48
|
+
const truncate = yield* Truncate.Service
|
|
49
|
+
|
|
50
|
+
const resolve = Effect.fn("SessionResolveTools.resolve")(function* (input: ResolveToolsInput) {
|
|
51
|
+
using _ = log.time("resolveTools")
|
|
52
|
+
const tools: Record<string, AITool> = {}
|
|
53
|
+
const bridge = yield* EffectBridge.make()
|
|
54
|
+
const promptOps: TaskPromptOps = {
|
|
55
|
+
cancel: (sessionID: SessionID) => Effect.void,
|
|
56
|
+
resolvePromptParts: (template: string) => Effect.succeed([{ type: "text" as const, text: template }]) as any,
|
|
57
|
+
prompt: (_input: any) => Effect.fail(new Error("not available")) as any,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({
|
|
61
|
+
sessionID: input.session.id,
|
|
62
|
+
abort: options.abortSignal!,
|
|
63
|
+
messageID: input.processor.message.id,
|
|
64
|
+
callID: options.toolCallId,
|
|
65
|
+
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps },
|
|
66
|
+
agent: input.agent.name,
|
|
67
|
+
messages: input.messages,
|
|
68
|
+
metadata: (val: any) =>
|
|
69
|
+
input.processor.updateToolCall(options.toolCallId, (match) => {
|
|
70
|
+
if (!["running", "pending"].includes(match.state.status)) return match
|
|
71
|
+
return {
|
|
72
|
+
...match,
|
|
73
|
+
state: {
|
|
74
|
+
title: val.title,
|
|
75
|
+
metadata: val.metadata,
|
|
76
|
+
status: "running",
|
|
77
|
+
input: args,
|
|
78
|
+
time: { start: Date.now() },
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
82
|
+
ask: (req: any) =>
|
|
83
|
+
permission
|
|
84
|
+
.ask({
|
|
85
|
+
...req,
|
|
86
|
+
sessionID: input.session.id,
|
|
87
|
+
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
|
88
|
+
ruleset: Permission.merge(
|
|
89
|
+
input.agent.permission,
|
|
90
|
+
SaeeolSessionPrompt.guardPermissions({ agent: input.agent, session: input.session }),
|
|
91
|
+
),
|
|
92
|
+
hardRuleset: SaeeolSessionPrompt.hardPermissions({ agent: input.agent }),
|
|
93
|
+
})
|
|
94
|
+
.pipe(Effect.orDie),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
for (const item of yield* registry.tools({
|
|
98
|
+
modelID: ModelID.make(input.model.api.id),
|
|
99
|
+
providerID: input.model.providerID,
|
|
100
|
+
agent: input.agent,
|
|
101
|
+
})) {
|
|
102
|
+
const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters))
|
|
103
|
+
tools[item.id] = tool({
|
|
104
|
+
description: item.description,
|
|
105
|
+
inputSchema: jsonSchema(schema),
|
|
106
|
+
execute(args, options) {
|
|
107
|
+
return bridge.promise(
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
const ctx = context(args, options)
|
|
110
|
+
yield* plugin.trigger(
|
|
111
|
+
"tool.execute.before",
|
|
112
|
+
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
|
|
113
|
+
{ args },
|
|
114
|
+
)
|
|
115
|
+
const result = yield* item.execute(args, ctx)
|
|
116
|
+
const output = {
|
|
117
|
+
...result,
|
|
118
|
+
attachments: result.attachments?.map((attachment) => ({
|
|
119
|
+
...attachment,
|
|
120
|
+
id: PartID.ascending(),
|
|
121
|
+
sessionID: ctx.sessionID,
|
|
122
|
+
messageID: input.processor.message.id,
|
|
123
|
+
})),
|
|
124
|
+
}
|
|
125
|
+
yield* plugin.trigger(
|
|
126
|
+
"tool.execute.after",
|
|
127
|
+
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
|
|
128
|
+
output,
|
|
129
|
+
)
|
|
130
|
+
if (options.abortSignal?.aborted) {
|
|
131
|
+
yield* input.processor.completeToolCall(options.toolCallId, output)
|
|
132
|
+
}
|
|
133
|
+
return output
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
|
141
|
+
const execute = item.execute
|
|
142
|
+
if (!execute) continue
|
|
143
|
+
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
|
|
144
|
+
const transformed = ProviderTransform.schema(input.model, schema)
|
|
145
|
+
item.inputSchema = jsonSchema(transformed)
|
|
146
|
+
item.execute = (args, opts) =>
|
|
147
|
+
bridge.promise(
|
|
148
|
+
Effect.gen(function* () {
|
|
149
|
+
const ctx = context(args, opts)
|
|
150
|
+
yield* plugin.trigger(
|
|
151
|
+
"tool.execute.before",
|
|
152
|
+
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
|
153
|
+
{ args },
|
|
154
|
+
)
|
|
155
|
+
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
|
|
156
|
+
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
|
157
|
+
return yield* Effect.promise(() => execute(args, opts))
|
|
158
|
+
}).pipe(
|
|
159
|
+
Effect.withSpan("Tool.execute", {
|
|
160
|
+
attributes: {
|
|
161
|
+
"tool.name": key,
|
|
162
|
+
"tool.call_id": opts.toolCallId,
|
|
163
|
+
"session.id": ctx.sessionID,
|
|
164
|
+
"message.id": input.processor.message.id,
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
)
|
|
168
|
+
yield* plugin.trigger(
|
|
169
|
+
"tool.execute.after",
|
|
170
|
+
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
|
171
|
+
result,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
const textParts: string[] = []
|
|
175
|
+
const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []
|
|
176
|
+
for (const contentItem of result.content) {
|
|
177
|
+
if (contentItem.type === "text") textParts.push(contentItem.text)
|
|
178
|
+
else if (contentItem.type === "image") {
|
|
179
|
+
attachments.push({
|
|
180
|
+
type: "file",
|
|
181
|
+
mime: contentItem.mimeType,
|
|
182
|
+
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
|
|
183
|
+
})
|
|
184
|
+
} else if (contentItem.type === "resource") {
|
|
185
|
+
const { resource } = contentItem
|
|
186
|
+
if (resource.text) textParts.push(resource.text)
|
|
187
|
+
if (resource.blob) {
|
|
188
|
+
attachments.push({
|
|
189
|
+
type: "file",
|
|
190
|
+
mime: resource.mimeType ?? "application/octet-stream",
|
|
191
|
+
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
|
|
192
|
+
filename: resource.uri,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
|
|
199
|
+
const metadata = {
|
|
200
|
+
...result.metadata,
|
|
201
|
+
truncated: truncated.truncated,
|
|
202
|
+
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
|
203
|
+
}
|
|
204
|
+
const output = {
|
|
205
|
+
title: "",
|
|
206
|
+
metadata,
|
|
207
|
+
output: truncated.content,
|
|
208
|
+
attachments: attachments.map((attachment) => ({
|
|
209
|
+
...attachment,
|
|
210
|
+
id: PartID.ascending(),
|
|
211
|
+
sessionID: ctx.sessionID,
|
|
212
|
+
messageID: input.processor.message.id,
|
|
213
|
+
})),
|
|
214
|
+
content: result.content,
|
|
215
|
+
}
|
|
216
|
+
if (opts.abortSignal?.aborted) {
|
|
217
|
+
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
|
218
|
+
}
|
|
219
|
+
return output
|
|
220
|
+
}),
|
|
221
|
+
)
|
|
222
|
+
tools[key] = item
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return tools
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
return { resolve }
|
|
229
|
+
}),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
export const defaultLayer = Layer.suspend(() =>
|
|
233
|
+
layer.pipe(
|
|
234
|
+
Layer.provide(ToolRegistry.defaultLayer),
|
|
235
|
+
Layer.provide(MCP.defaultLayer),
|
|
236
|
+
Layer.provide(Plugin.defaultLayer),
|
|
237
|
+
Layer.provide(Permission.defaultLayer),
|
|
238
|
+
Layer.provide(Truncate.defaultLayer),
|
|
239
|
+
Layer.provide(Provider.defaultLayer),
|
|
240
|
+
),
|
|
241
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { NamedError } from "@saeeol/core/util/error"
|
|
2
|
+
import { Cause, Clock, Duration, Effect, Schedule } from "effect"
|
|
3
|
+
import { MessageV2 } from "../message/message-v2"
|
|
4
|
+
import { isSaeeolError } from "@/saeeol/errors"
|
|
5
|
+
import { SessionNetwork } from "./network"
|
|
6
|
+
import { iife } from "@/util/iife"
|
|
7
|
+
|
|
8
|
+
export type Err = ReturnType<NamedError["toObject"]>
|
|
9
|
+
|
|
10
|
+
// This exported message is shared with the TUI upsell detector. Matching on a
|
|
11
|
+
// literal error string kind of sucks, but it is the simplest for now.
|
|
12
|
+
export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://saeeol.ai/go"
|
|
13
|
+
|
|
14
|
+
export const RETRY_INITIAL_DELAY = 2000
|
|
15
|
+
export const RETRY_BACKOFF_FACTOR = 2
|
|
16
|
+
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
|
|
17
|
+
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
|
|
18
|
+
|
|
19
|
+
function cap(ms: number) {
|
|
20
|
+
return Math.min(ms, RETRY_MAX_DELAY)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function delay(attempt: number, error?: MessageV2.APIError) {
|
|
24
|
+
if (error) {
|
|
25
|
+
const headers = error.data.responseHeaders
|
|
26
|
+
if (headers) {
|
|
27
|
+
const retryAfterMs = headers["retry-after-ms"]
|
|
28
|
+
if (retryAfterMs) {
|
|
29
|
+
const parsedMs = Number.parseFloat(retryAfterMs)
|
|
30
|
+
if (!Number.isNaN(parsedMs)) {
|
|
31
|
+
return cap(parsedMs)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const retryAfter = headers["retry-after"]
|
|
36
|
+
if (retryAfter) {
|
|
37
|
+
const parsedSeconds = Number.parseFloat(retryAfter)
|
|
38
|
+
if (!Number.isNaN(parsedSeconds)) {
|
|
39
|
+
// convert seconds to milliseconds
|
|
40
|
+
return cap(Math.ceil(parsedSeconds * 1000))
|
|
41
|
+
}
|
|
42
|
+
// Try parsing as HTTP date format
|
|
43
|
+
const parsed = Date.parse(retryAfter) - Date.now()
|
|
44
|
+
if (!Number.isNaN(parsed) && parsed > 0) {
|
|
45
|
+
return cap(Math.ceil(parsed))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function retryable(error: Err) {
|
|
57
|
+
// context overflow errors should not be retried
|
|
58
|
+
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
|
|
59
|
+
if (MessageV2.APIError.isInstance(error)) {
|
|
60
|
+
const status = error.data.statusCode
|
|
61
|
+
if (isSaeeolError(error)) return undefined
|
|
62
|
+
|
|
63
|
+
// 5xx errors are transient server failures and should always be retried,
|
|
64
|
+
// even when the provider SDK doesn't explicitly mark them as retryable.
|
|
65
|
+
if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined
|
|
66
|
+
// capped model is futile and the backoff loop cannot be broken by switching
|
|
67
|
+
// models in the chat selector (the retry loop holds a stale model ref).
|
|
68
|
+
if (error.data.responseBody?.includes("FreeUsageLimitError")) return undefined
|
|
69
|
+
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check for rate limit patterns in plain text error messages
|
|
73
|
+
const msg = error.data?.message
|
|
74
|
+
if (typeof msg === "string") {
|
|
75
|
+
const lower = msg.toLowerCase()
|
|
76
|
+
if (
|
|
77
|
+
lower.includes("rate increased too quickly") ||
|
|
78
|
+
lower.includes("rate limit") ||
|
|
79
|
+
lower.includes("too many requests")
|
|
80
|
+
) {
|
|
81
|
+
return msg
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const json = iife(() => {
|
|
86
|
+
try {
|
|
87
|
+
if (typeof error.data?.message === "string") {
|
|
88
|
+
const parsed = JSON.parse(error.data.message)
|
|
89
|
+
return parsed
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return JSON.parse(error.data.message)
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
if (!json || typeof json !== "object") return undefined
|
|
98
|
+
const code = typeof json.code === "string" ? json.code : ""
|
|
99
|
+
|
|
100
|
+
if (json.type === "error" && json.error?.type === "too_many_requests") {
|
|
101
|
+
return "Too Many Requests"
|
|
102
|
+
}
|
|
103
|
+
if (code.includes("exhausted") || code.includes("unavailable")) {
|
|
104
|
+
return "Provider is overloaded"
|
|
105
|
+
}
|
|
106
|
+
if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
|
|
107
|
+
return "Rate Limited"
|
|
108
|
+
}
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function policy(opts: {
|
|
113
|
+
parse: (error: unknown) => Err
|
|
114
|
+
set: (input: { attempt: number; message: string; next: number }) => Effect.Effect<void>
|
|
115
|
+
limit?: number
|
|
116
|
+
offline?: (input: { error: unknown; message: string }) => Effect.Effect<"retry" | "blocked" | "aborted">
|
|
117
|
+
}) {
|
|
118
|
+
return Schedule.fromStepWithMetadata(
|
|
119
|
+
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
|
|
120
|
+
if (opts.limit !== undefined && meta.attempt > opts.limit) {
|
|
121
|
+
return Cause.done(meta.attempt)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const error = opts.parse(meta.input)
|
|
125
|
+
const message = retryable(error)
|
|
126
|
+
if (!message) return Cause.done(meta.attempt)
|
|
127
|
+
return Effect.gen(function* () {
|
|
128
|
+
if (opts.offline && SessionNetwork.disconnected(meta.input)) {
|
|
129
|
+
const result = yield* opts.offline({
|
|
130
|
+
error: meta.input,
|
|
131
|
+
message: SessionNetwork.message(meta.input),
|
|
132
|
+
})
|
|
133
|
+
if (result !== "retry") {
|
|
134
|
+
return yield* Cause.done(meta.attempt)
|
|
135
|
+
}
|
|
136
|
+
yield* opts.set({ attempt: 0, message: "Reconnected", next: Date.now() })
|
|
137
|
+
return [0, Duration.zero] as [number, Duration.Duration]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined)
|
|
141
|
+
const now = yield* Clock.currentTimeMillis
|
|
142
|
+
yield* opts.set({ attempt: meta.attempt, message, next: now + wait })
|
|
143
|
+
return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration]
|
|
144
|
+
})
|
|
145
|
+
}),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export * as SessionRetry from "./retry"
|