@sixb/pg 0.1.21 → 0.1.22
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/README.md +1 -0
- package/dist/agents/agent-storage.d.ts +8 -2
- package/dist/agents/agent-storage.d.ts.map +1 -1
- package/dist/agents/approvals.d.ts +48 -0
- package/dist/agents/approvals.d.ts.map +1 -0
- package/dist/agents/checkpoints.d.ts +1 -0
- package/dist/agents/checkpoints.d.ts.map +1 -1
- package/dist/agents/index.d.ts +1 -0
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/memory.d.ts +19 -0
- package/dist/agents/memory.d.ts.map +1 -0
- package/dist/agents/messages.d.ts.map +1 -1
- package/dist/agents/rows.d.ts +15 -1
- package/dist/agents/rows.d.ts.map +1 -1
- package/dist/agents/runs.d.ts +3 -1
- package/dist/agents/runs.d.ts.map +1 -1
- package/dist/agents/settings.d.ts +29 -0
- package/dist/agents/settings.d.ts.map +1 -0
- package/dist/agents/threads.d.ts +6 -1
- package/dist/agents/threads.d.ts.map +1 -1
- package/dist/ai-cost-groups.d.ts.map +1 -1
- package/dist/auth-storage/auth-storage.d.ts +3 -2
- package/dist/auth-storage/auth-storage.d.ts.map +1 -1
- package/dist/auth-storage/users.d.ts +8 -4
- package/dist/auth-storage/users.d.ts.map +1 -1
- package/dist/index.js +3720 -1594
- package/dist/index.js.map +37 -31
- package/dist/migrations.d.ts.map +1 -1
- package/dist/objects/conditional-reader.d.ts +17 -0
- package/dist/objects/conditional-reader.d.ts.map +1 -0
- package/dist/objects/query-compiler.d.ts +27 -1
- package/dist/objects/query-compiler.d.ts.map +1 -1
- package/dist/objects/query-indexes.d.ts.map +1 -1
- package/dist/objects/read-scope.d.ts +10 -1
- package/dist/objects/read-scope.d.ts.map +1 -1
- package/dist/objects/reader.d.ts +2 -0
- package/dist/objects/reader.d.ts.map +1 -1
- package/dist/objects/scoped-reader.d.ts +12 -0
- package/dist/objects/scoped-reader.d.ts.map +1 -0
- package/dist/objects/selected-reader.d.ts +1 -0
- package/dist/objects/selected-reader.d.ts.map +1 -1
- package/dist/objects/storage.d.ts +7 -1
- package/dist/objects/storage.d.ts.map +1 -1
- package/dist/ontology-storage/index.d.ts +2 -0
- package/dist/ontology-storage/index.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-session.d.ts +6 -0
- package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-writer.d.ts +15 -0
- package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
- package/dist/ontology-storage/materializations.d.ts.map +1 -1
- package/dist/ontology-storage/unique-values.d.ts +60 -0
- package/dist/ontology-storage/unique-values.d.ts.map +1 -0
- package/dist/pg-ai-cost-storage.d.ts.map +1 -1
- package/dist/pg-ai-usage-storage.d.ts +8 -1
- package/dist/pg-ai-usage-storage.d.ts.map +1 -1
- package/dist/pg-artifact-storage.d.ts +8 -1
- package/dist/pg-artifact-storage.d.ts.map +1 -1
- package/dist/pg-execution-storage.d.ts +2 -1
- package/dist/pg-execution-storage.d.ts.map +1 -1
- package/dist/storage-errors.d.ts +2 -0
- package/dist/storage-errors.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/agents/agent-storage.ts +11 -2
- package/src/agents/approvals.ts +400 -0
- package/src/agents/checkpoints.ts +8 -4
- package/src/agents/index.ts +1 -0
- package/src/agents/memory.ts +175 -0
- package/src/agents/messages.ts +14 -7
- package/src/agents/rows.ts +46 -1
- package/src/agents/runs.ts +154 -22
- package/src/agents/settings.ts +105 -0
- package/src/agents/threads.ts +356 -3
- package/src/ai-cost-groups.ts +11 -1
- package/src/auth-storage/auth-storage.ts +45 -13
- package/src/auth-storage/users.ts +105 -43
- package/src/migrations/069-artifact-kinds.sql +4 -0
- package/src/migrations/070-artifact-state.sql +16 -0
- package/src/migrations/071-agent-thread-participants.sql +16 -0
- package/src/migrations/072-forked-context-checkpoints.sql +23 -0
- package/src/migrations/073-unique-values.sql +25 -0
- package/src/migrations/074-generated-media-usage.sql +21 -0
- package/src/migrations/075-model-call-files.sql +11 -0
- package/src/migrations/076-agent-memory.sql +33 -0
- package/src/migrations/077-artifact-threads.sql +21 -0
- package/src/migrations/078-agent-approvals.sql +58 -0
- package/src/migrations/079-agent-approval-resume.sql +20 -0
- package/src/migrations/080-subagent-threads.sql +145 -0
- package/src/migrations/081-subagent-questions.sql +6 -0
- package/src/migrations.ts +34 -0
- package/src/objects/conditional-reader.ts +44 -0
- package/src/objects/prepare-queries.ts +1 -1
- package/src/objects/query-compiler.ts +96 -33
- package/src/objects/query-indexes.ts +29 -10
- package/src/objects/read-scope.ts +140 -25
- package/src/objects/reader.ts +50 -6
- package/src/objects/scoped-reader.ts +101 -0
- package/src/objects/selected-reader.ts +16 -76
- package/src/objects/storage.ts +24 -5
- package/src/objects/text-count-index.ts +1 -0
- package/src/ontology-storage/index.ts +3 -0
- package/src/ontology-storage/materialization-session.ts +8 -0
- package/src/ontology-storage/materialization-writer.ts +76 -1
- package/src/ontology-storage/materializations.ts +20 -1
- package/src/ontology-storage/unique-values.ts +274 -0
- package/src/pg-ai-cost-storage.ts +62 -12
- package/src/pg-ai-usage-storage.ts +90 -1
- package/src/pg-artifact-storage.ts +205 -9
- package/src/pg-execution-storage.ts +31 -0
- package/src/storage-errors.ts +7 -0
- package/src/transactions.ts +1 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { AgentStorage, ExecutionStorage } from "@sixb/core/storage"
|
|
2
2
|
import type { PgStoreClient } from "../transactions"
|
|
3
|
+
import { PgAgentApprovalStore } from "./approvals"
|
|
3
4
|
import { PgAgentContextCheckpointStore } from "./checkpoints"
|
|
5
|
+
import { PgAgentMemoryStore } from "./memory"
|
|
4
6
|
import { PgAgentMessageStore } from "./messages"
|
|
5
7
|
import { PgAgentRunStore } from "./runs"
|
|
8
|
+
import { PgAgentApprovalModeStore } from "./settings"
|
|
6
9
|
import { PgAgentThreadStore } from "./threads"
|
|
7
10
|
|
|
8
11
|
export interface PgAgentStorageOptions {
|
|
@@ -11,8 +14,8 @@ export interface PgAgentStorageOptions {
|
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
|
-
* PostgreSQL-backed agent persistence: thread, run, message,
|
|
15
|
-
* sharing one connection. Cross-table writes run inside `runPgTransaction` with `SELECT ... FOR
|
|
17
|
+
* PostgreSQL-backed agent persistence: thread, run, message, context-checkpoint, memory, approval
|
|
18
|
+
* and approval-mode sub-stores sharing one connection. Cross-table writes run inside `runPgTransaction` with `SELECT ... FOR
|
|
16
19
|
* UPDATE` row locks for single-flight and checkpoint compare-and-swap safety.
|
|
17
20
|
*/
|
|
18
21
|
export class PgAgentStorage implements AgentStorage {
|
|
@@ -20,11 +23,17 @@ export class PgAgentStorage implements AgentStorage {
|
|
|
20
23
|
readonly runs: PgAgentRunStore
|
|
21
24
|
readonly messages: PgAgentMessageStore
|
|
22
25
|
readonly checkpoints: PgAgentContextCheckpointStore
|
|
26
|
+
readonly memory: PgAgentMemoryStore
|
|
27
|
+
readonly approvals: PgAgentApprovalStore
|
|
28
|
+
readonly approvalModes: PgAgentApprovalModeStore
|
|
23
29
|
|
|
24
30
|
constructor(options: PgAgentStorageOptions) {
|
|
25
31
|
this.threads = new PgAgentThreadStore(options.sql)
|
|
26
32
|
this.runs = new PgAgentRunStore(options.sql, options.executions)
|
|
27
33
|
this.messages = new PgAgentMessageStore(options.sql)
|
|
28
34
|
this.checkpoints = new PgAgentContextCheckpointStore(options.sql)
|
|
35
|
+
this.memory = new PgAgentMemoryStore(options.sql)
|
|
36
|
+
this.approvals = new PgAgentApprovalStore(options.sql)
|
|
37
|
+
this.approvalModes = new PgAgentApprovalModeStore(options.sql)
|
|
29
38
|
}
|
|
30
39
|
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import type { Principal } from "@sixb/core"
|
|
2
|
+
import {
|
|
3
|
+
agentApprovalCallKey,
|
|
4
|
+
assertAgentApprovalCaller,
|
|
5
|
+
assertRequestAgentApprovalInput,
|
|
6
|
+
newAgentApprovalRecord,
|
|
7
|
+
} from "@sixb/core/internal/agent-run-storage-provider"
|
|
8
|
+
import {
|
|
9
|
+
type AgentApprovalCall,
|
|
10
|
+
type AgentApprovalCursor,
|
|
11
|
+
type AgentApprovalMode,
|
|
12
|
+
type AgentApprovalOutcome,
|
|
13
|
+
type AgentApprovalRecord,
|
|
14
|
+
type AgentApprovalStore,
|
|
15
|
+
AgentStorageError,
|
|
16
|
+
type DecideAgentApprovalInput,
|
|
17
|
+
type DecideAgentApprovalResult,
|
|
18
|
+
type ListAgentApprovalsInput,
|
|
19
|
+
type ListAgentApprovalsResult,
|
|
20
|
+
type MarkAgentApprovalsReportedInput,
|
|
21
|
+
type RequestAgentApprovalInput,
|
|
22
|
+
type RequestAgentApprovalResult,
|
|
23
|
+
type SettleAgentApprovalsInput,
|
|
24
|
+
type StopAgentApprovalsInput,
|
|
25
|
+
type StopAgentApprovalsResult,
|
|
26
|
+
} from "@sixb/core/storage"
|
|
27
|
+
import type { SQLClient } from "../pg-client"
|
|
28
|
+
import { type PgStoreClient, runPgTransaction } from "../transactions"
|
|
29
|
+
import { type AgentRunRow, rowToRunRecord } from "./rows"
|
|
30
|
+
|
|
31
|
+
interface AgentApprovalRow {
|
|
32
|
+
readonly project_id: string
|
|
33
|
+
readonly id: string
|
|
34
|
+
readonly thread_id: string
|
|
35
|
+
readonly run_id: string
|
|
36
|
+
readonly caller_run_id: string
|
|
37
|
+
readonly requested_by_type: Principal["type"]
|
|
38
|
+
readonly requested_by_id: string
|
|
39
|
+
readonly mode: AgentApprovalRecord["mode"]
|
|
40
|
+
readonly call_key: string
|
|
41
|
+
readonly call: AgentApprovalCall | string
|
|
42
|
+
readonly status: AgentApprovalRecord["status"]
|
|
43
|
+
readonly created_at: Date | string
|
|
44
|
+
readonly expires_at: Date | string
|
|
45
|
+
readonly decided_at: Date | string | null
|
|
46
|
+
readonly decided_by_type: Principal["type"] | null
|
|
47
|
+
readonly decided_by_id: string | null
|
|
48
|
+
readonly outcome: AgentApprovalOutcome | string | null
|
|
49
|
+
readonly reported_in_message_id: string | null
|
|
50
|
+
readonly raised_mode: AgentApprovalMode | null
|
|
51
|
+
readonly turn_stopped_at: Date | string | null
|
|
52
|
+
readonly execution_claimed_at: Date | string | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class PgAgentApprovalStore implements AgentApprovalStore {
|
|
56
|
+
constructor(private readonly sql: PgStoreClient) {}
|
|
57
|
+
|
|
58
|
+
async request(input: RequestAgentApprovalInput): Promise<RequestAgentApprovalResult> {
|
|
59
|
+
assertRequestAgentApprovalInput(input, "SixbPg")
|
|
60
|
+
|
|
61
|
+
return runPgTransaction(this.sql, async (tx) => {
|
|
62
|
+
// The caller's row lock serializes its requests with its `finish`, which cancels them.
|
|
63
|
+
const turn = await lockRun(tx, input.projectId, input.runId)
|
|
64
|
+
const caller =
|
|
65
|
+
input.callerRunId === input.runId
|
|
66
|
+
? turn
|
|
67
|
+
: await lockRun(tx, input.projectId, input.callerRunId)
|
|
68
|
+
const threadId = assertAgentApprovalCaller({ request: input, turn, caller, prefix: "SixbPg" })
|
|
69
|
+
|
|
70
|
+
const live = await selectLive(tx, input.projectId, threadId, agentApprovalCallKey(input.call))
|
|
71
|
+
if (live) return { approval: rowToApproval(live), created: false }
|
|
72
|
+
|
|
73
|
+
const approval = newAgentApprovalRecord(input, threadId)
|
|
74
|
+
const [inserted] = await tx<AgentApprovalRow[]>`
|
|
75
|
+
INSERT INTO agent_approvals (
|
|
76
|
+
project_id, id, thread_id, run_id, caller_run_id, requested_by_type, requested_by_id,
|
|
77
|
+
mode, call_key, call, status, created_at, expires_at
|
|
78
|
+
) VALUES (
|
|
79
|
+
${approval.projectId}, ${approval.id}, ${approval.threadId}, ${approval.runId},
|
|
80
|
+
${approval.callerRunId}, ${approval.requestedBy.type}, ${approval.requestedBy.id},
|
|
81
|
+
${approval.mode}, ${approval.callKey}, ${JSON.stringify(approval.call)}::text::jsonb,
|
|
82
|
+
'pending', ${approval.createdAt}, ${approval.expiresAt}
|
|
83
|
+
)
|
|
84
|
+
ON CONFLICT DO NOTHING
|
|
85
|
+
RETURNING *
|
|
86
|
+
`
|
|
87
|
+
if (inserted) return { approval: rowToApproval(inserted), created: true }
|
|
88
|
+
|
|
89
|
+
// Another caller of the same turn held the call first.
|
|
90
|
+
const raced = await selectLive(tx, input.projectId, threadId, approval.callKey)
|
|
91
|
+
if (raced) return { approval: rowToApproval(raced), created: false }
|
|
92
|
+
throw new AgentStorageError(
|
|
93
|
+
"duplicate_id",
|
|
94
|
+
`[SixbPg] Agent approval '${input.id}' already exists for project '${input.projectId}'.`
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async decide(input: DecideAgentApprovalInput): Promise<DecideAgentApprovalResult | null> {
|
|
100
|
+
const decidedAt = input.decidedAt ?? new Date()
|
|
101
|
+
const [row] = await this.sql<AgentApprovalRow[]>`
|
|
102
|
+
UPDATE agent_approvals
|
|
103
|
+
SET
|
|
104
|
+
status = CASE WHEN expires_at <= ${decidedAt} THEN 'expired' ELSE ${input.decision} END,
|
|
105
|
+
decided_at = CASE WHEN expires_at <= ${decidedAt} THEN NULL ELSE ${decidedAt}::timestamptz END,
|
|
106
|
+
decided_by_type = CASE WHEN expires_at <= ${decidedAt} THEN NULL ELSE ${input.decidedBy.type} END,
|
|
107
|
+
decided_by_id = CASE WHEN expires_at <= ${decidedAt} THEN NULL ELSE ${input.decidedBy.id} END,
|
|
108
|
+
raised_mode = CASE WHEN expires_at <= ${decidedAt} OR ${input.decision} <> 'approved'
|
|
109
|
+
THEN NULL ELSE ${input.raisedMode ?? null}::text END
|
|
110
|
+
WHERE project_id = ${input.projectId} AND id = ${input.id} AND status = 'pending'
|
|
111
|
+
RETURNING *
|
|
112
|
+
`
|
|
113
|
+
if (row) {
|
|
114
|
+
const approval = rowToApproval(row)
|
|
115
|
+
return { approval, decided: approval.status === input.decision }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const current = await this.getById(input)
|
|
119
|
+
return current ? { approval: current, decided: false } : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async recordOutcome(input: {
|
|
123
|
+
readonly projectId: string
|
|
124
|
+
readonly id: string
|
|
125
|
+
readonly outcome: AgentApprovalOutcome
|
|
126
|
+
}): Promise<AgentApprovalRecord> {
|
|
127
|
+
const [row] = await this.sql<AgentApprovalRow[]>`
|
|
128
|
+
UPDATE agent_approvals
|
|
129
|
+
SET outcome = ${JSON.stringify(input.outcome)}::text::jsonb
|
|
130
|
+
WHERE project_id = ${input.projectId} AND id = ${input.id}
|
|
131
|
+
AND status = 'approved' AND outcome IS NULL
|
|
132
|
+
RETURNING *
|
|
133
|
+
`
|
|
134
|
+
if (row) return rowToApproval(row)
|
|
135
|
+
|
|
136
|
+
const current = await this.getById(input)
|
|
137
|
+
if (current?.status === "approved") return current
|
|
138
|
+
throw new AgentStorageError(
|
|
139
|
+
"invalid_state",
|
|
140
|
+
current
|
|
141
|
+
? `[SixbPg] Agent approval '${input.id}' is not approved (status '${current.status}').`
|
|
142
|
+
: `[SixbPg] Agent approval '${input.id}' not found for project '${input.projectId}'.`
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async settle(input: SettleAgentApprovalsInput): Promise<readonly AgentApprovalRecord[]> {
|
|
147
|
+
const rows = await this.sql<AgentApprovalRow[]>`
|
|
148
|
+
UPDATE agent_approvals SET status = ${input.status}
|
|
149
|
+
WHERE project_id = ${input.projectId} AND thread_id = ${input.threadId}
|
|
150
|
+
AND status = 'pending'
|
|
151
|
+
RETURNING *
|
|
152
|
+
`
|
|
153
|
+
return rows.map(rowToApproval).sort(compareApprovals)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async stop(input: StopAgentApprovalsInput): Promise<StopAgentApprovalsResult> {
|
|
157
|
+
const runId = input.runId ?? null
|
|
158
|
+
const requesterType = input.requestedBy?.type ?? null
|
|
159
|
+
const requesterId = input.requestedBy?.id ?? null
|
|
160
|
+
const stoppedAt = input.stoppedAt ?? new Date()
|
|
161
|
+
|
|
162
|
+
// What each row was before the update tells what this stop changed.
|
|
163
|
+
const rows = await this.sql<
|
|
164
|
+
(AgentApprovalRow & { readonly was_pending: boolean; readonly newly_stopped: boolean })[]
|
|
165
|
+
>`
|
|
166
|
+
WITH matched AS (
|
|
167
|
+
SELECT id, status = 'pending' AS was_pending, turn_stopped_at IS NULL AS newly_stopped
|
|
168
|
+
FROM agent_approvals
|
|
169
|
+
WHERE project_id = ${input.projectId} AND thread_id = ${input.threadId}
|
|
170
|
+
AND reported_in_message_id IS NULL
|
|
171
|
+
AND (status = 'pending' OR turn_stopped_at IS NULL)
|
|
172
|
+
AND (${runId}::text IS NULL OR run_id = ${runId})
|
|
173
|
+
AND (${requesterType}::text IS NULL
|
|
174
|
+
OR (requested_by_type = ${requesterType} AND requested_by_id = ${requesterId}))
|
|
175
|
+
FOR UPDATE
|
|
176
|
+
)
|
|
177
|
+
UPDATE agent_approvals AS approval
|
|
178
|
+
SET
|
|
179
|
+
status = CASE WHEN matched.was_pending THEN 'cancelled' ELSE approval.status END,
|
|
180
|
+
turn_stopped_at = COALESCE(approval.turn_stopped_at, ${stoppedAt}::timestamptz)
|
|
181
|
+
FROM matched
|
|
182
|
+
WHERE approval.project_id = ${input.projectId} AND approval.id = matched.id
|
|
183
|
+
RETURNING approval.*, matched.was_pending, matched.newly_stopped
|
|
184
|
+
`
|
|
185
|
+
const cancelled = rows.filter((row) => row.was_pending).map(rowToApproval)
|
|
186
|
+
const stopped = rows.filter((row) => row.newly_stopped).length
|
|
187
|
+
return { cancelled: cancelled.sort(compareApprovals), stopped }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async holdsRun(input: { readonly projectId: string; readonly runId: string }): Promise<boolean> {
|
|
191
|
+
return pgHoldsRun(this.sql, input.projectId, input.runId)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async claimExecution(input: {
|
|
195
|
+
readonly projectId: string
|
|
196
|
+
readonly id: string
|
|
197
|
+
readonly claimedAt: Date
|
|
198
|
+
readonly staleBefore: Date
|
|
199
|
+
}): Promise<AgentApprovalRecord | null> {
|
|
200
|
+
const [row] = await this.sql<AgentApprovalRow[]>`
|
|
201
|
+
UPDATE agent_approvals SET execution_claimed_at = ${input.claimedAt}
|
|
202
|
+
WHERE project_id = ${input.projectId} AND id = ${input.id}
|
|
203
|
+
AND status = 'approved' AND outcome IS NULL
|
|
204
|
+
AND COALESCE(execution_claimed_at, decided_at) <= ${input.staleBefore}
|
|
205
|
+
RETURNING *
|
|
206
|
+
`
|
|
207
|
+
return row ? rowToApproval(row) : null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async listRepairable(input: {
|
|
211
|
+
readonly projectId: string
|
|
212
|
+
readonly claimableBefore: Date
|
|
213
|
+
readonly after?: AgentApprovalCursor
|
|
214
|
+
readonly limit: number
|
|
215
|
+
}): Promise<readonly AgentApprovalRecord[]> {
|
|
216
|
+
const afterAt = input.after?.createdAt ?? null
|
|
217
|
+
const afterId = input.after?.id ?? ""
|
|
218
|
+
// Each half of the OR matches one partial index.
|
|
219
|
+
const rows = await this.sql<AgentApprovalRow[]>`
|
|
220
|
+
SELECT * FROM agent_approvals
|
|
221
|
+
WHERE project_id = ${input.projectId}
|
|
222
|
+
AND (
|
|
223
|
+
(status = 'approved' AND outcome IS NULL
|
|
224
|
+
AND COALESCE(execution_claimed_at, decided_at) <= ${input.claimableBefore})
|
|
225
|
+
OR (status IN ('approved', 'denied') AND reported_in_message_id IS NULL
|
|
226
|
+
AND turn_stopped_at IS NULL)
|
|
227
|
+
)
|
|
228
|
+
AND (${afterAt}::timestamptz IS NULL OR (created_at, id) > (${afterAt}::timestamptz, ${afterId}))
|
|
229
|
+
ORDER BY created_at, id
|
|
230
|
+
LIMIT ${input.limit}
|
|
231
|
+
`
|
|
232
|
+
return rows.map(rowToApproval)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async expireDue(input: {
|
|
236
|
+
readonly projectId: string
|
|
237
|
+
readonly now: Date
|
|
238
|
+
readonly limit?: number
|
|
239
|
+
}): Promise<readonly AgentApprovalRecord[]> {
|
|
240
|
+
// The CTE picks the rows once, under the limit; an `IN` subquery did not keep to it.
|
|
241
|
+
const rows = await this.sql<AgentApprovalRow[]>`
|
|
242
|
+
WITH due AS (
|
|
243
|
+
SELECT id FROM agent_approvals
|
|
244
|
+
WHERE project_id = ${input.projectId} AND status = 'pending'
|
|
245
|
+
AND expires_at <= ${input.now}
|
|
246
|
+
ORDER BY created_at, id
|
|
247
|
+
LIMIT ${input.limit ?? null}
|
|
248
|
+
FOR UPDATE SKIP LOCKED
|
|
249
|
+
)
|
|
250
|
+
UPDATE agent_approvals AS approval SET status = 'expired'
|
|
251
|
+
FROM due
|
|
252
|
+
WHERE approval.project_id = ${input.projectId} AND approval.id = due.id
|
|
253
|
+
RETURNING approval.*
|
|
254
|
+
`
|
|
255
|
+
return rows.map(rowToApproval).sort(compareApprovals)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async markReported(input: MarkAgentApprovalsReportedInput): Promise<readonly string[]> {
|
|
259
|
+
const ids = [...new Set(input.ids)]
|
|
260
|
+
if (ids.length === 0) return []
|
|
261
|
+
|
|
262
|
+
const rows = await this.sql<{ id: string }[]>`
|
|
263
|
+
UPDATE agent_approvals SET reported_in_message_id = ${input.messageId}
|
|
264
|
+
WHERE project_id = ${input.projectId} AND thread_id = ${input.threadId}
|
|
265
|
+
AND id = ANY(${this.sql.array(ids)}::text[]) AND reported_in_message_id IS NULL
|
|
266
|
+
AND (${input.resuming !== true} OR turn_stopped_at IS NULL)
|
|
267
|
+
RETURNING id
|
|
268
|
+
`
|
|
269
|
+
const marked = new Set(rows.map((row) => row.id))
|
|
270
|
+
return ids.filter((id) => marked.has(id))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async getById(params: {
|
|
274
|
+
readonly projectId: string
|
|
275
|
+
readonly id: string
|
|
276
|
+
}): Promise<AgentApprovalRecord | null> {
|
|
277
|
+
const [row] = await this.sql<AgentApprovalRow[]>`
|
|
278
|
+
SELECT * FROM agent_approvals WHERE project_id = ${params.projectId} AND id = ${params.id}
|
|
279
|
+
`
|
|
280
|
+
return row ? rowToApproval(row) : null
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async list(input: ListAgentApprovalsInput): Promise<ListAgentApprovalsResult> {
|
|
284
|
+
if (input.statuses?.length === 0) return { approvals: [], hasMore: false, total: 0 }
|
|
285
|
+
|
|
286
|
+
const offset = input.offset ?? 0
|
|
287
|
+
return runPgTransaction(
|
|
288
|
+
this.sql,
|
|
289
|
+
async (tx) => {
|
|
290
|
+
const [counted] = await tx<{ total: number }[]>`
|
|
291
|
+
SELECT COUNT(*)::int AS total FROM agent_approvals WHERE ${listFilter(tx, input)}
|
|
292
|
+
`
|
|
293
|
+
const rows = await tx<AgentApprovalRow[]>`
|
|
294
|
+
SELECT * FROM agent_approvals WHERE ${listFilter(tx, input)}
|
|
295
|
+
ORDER BY created_at, id
|
|
296
|
+
LIMIT ${input.limit ?? null} OFFSET ${offset}
|
|
297
|
+
`
|
|
298
|
+
const total = counted?.total ?? 0
|
|
299
|
+
return { approvals: rows.map(rowToApproval), hasMore: offset + rows.length < total, total }
|
|
300
|
+
},
|
|
301
|
+
{ isolation: "repeatableRead" }
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The `list` predicate, as the in-memory store's `agentApprovalMatches` reads it. */
|
|
307
|
+
function listFilter(sql: SQLClient, input: ListAgentApprovalsInput) {
|
|
308
|
+
const threadId = input.threadId ?? null
|
|
309
|
+
const runId = input.runId ?? null
|
|
310
|
+
const requesterType = input.requestedBy?.type ?? null
|
|
311
|
+
const requesterId = input.requestedBy?.id ?? null
|
|
312
|
+
const reported = input.reported ?? null
|
|
313
|
+
const statuses = input.statuses ?? null
|
|
314
|
+
|
|
315
|
+
return sql`
|
|
316
|
+
project_id = ${input.projectId}
|
|
317
|
+
AND (${threadId}::text IS NULL OR thread_id = ${threadId})
|
|
318
|
+
AND (${runId}::text IS NULL OR run_id = ${runId})
|
|
319
|
+
AND (${requesterType}::text IS NULL
|
|
320
|
+
OR (requested_by_type = ${requesterType} AND requested_by_id = ${requesterId}))
|
|
321
|
+
AND (${statuses === null}::boolean OR status = ANY(${sql.array([...(statuses ?? [])])}::text[]))
|
|
322
|
+
AND (${reported}::boolean IS NULL OR (reported_in_message_id IS NOT NULL) = ${reported})
|
|
323
|
+
`
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function lockRun(sql: SQLClient, projectId: string, id: string) {
|
|
327
|
+
const [row] = await sql<AgentRunRow[]>`
|
|
328
|
+
SELECT * FROM agent_runs WHERE project_id = ${projectId} AND id = ${id} FOR UPDATE
|
|
329
|
+
`
|
|
330
|
+
return row ? rowToRunRecord(row) : null
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function selectLive(
|
|
334
|
+
sql: SQLClient,
|
|
335
|
+
projectId: string,
|
|
336
|
+
threadId: string,
|
|
337
|
+
callKey: string
|
|
338
|
+
): Promise<AgentApprovalRow | undefined> {
|
|
339
|
+
return sql<AgentApprovalRow[]>`
|
|
340
|
+
SELECT * FROM agent_approvals
|
|
341
|
+
WHERE project_id = ${projectId} AND thread_id = ${threadId} AND call_key = ${callKey}
|
|
342
|
+
AND status IN ('pending', 'approved', 'denied')
|
|
343
|
+
`.then((rows) => rows[0])
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function rowToApproval(row: AgentApprovalRow): AgentApprovalRecord {
|
|
347
|
+
return {
|
|
348
|
+
id: row.id,
|
|
349
|
+
projectId: row.project_id,
|
|
350
|
+
threadId: row.thread_id,
|
|
351
|
+
runId: row.run_id,
|
|
352
|
+
callerRunId: row.caller_run_id,
|
|
353
|
+
requestedBy: { type: row.requested_by_type, id: row.requested_by_id },
|
|
354
|
+
mode: row.mode,
|
|
355
|
+
callKey: row.call_key,
|
|
356
|
+
call: parseJson<AgentApprovalCall>(row.call),
|
|
357
|
+
status: row.status,
|
|
358
|
+
createdAt: new Date(row.created_at),
|
|
359
|
+
expiresAt: new Date(row.expires_at),
|
|
360
|
+
...(row.decided_at === null ? {} : { decidedAt: new Date(row.decided_at) }),
|
|
361
|
+
...(row.decided_by_type === null || row.decided_by_id === null
|
|
362
|
+
? {}
|
|
363
|
+
: { decidedBy: { type: row.decided_by_type, id: row.decided_by_id } }),
|
|
364
|
+
...(row.outcome === null ? {} : { outcome: parseJson<AgentApprovalOutcome>(row.outcome) }),
|
|
365
|
+
...(row.reported_in_message_id === null
|
|
366
|
+
? {}
|
|
367
|
+
: { reportedInMessageId: row.reported_in_message_id }),
|
|
368
|
+
...(row.raised_mode === null ? {} : { raisedMode: row.raised_mode }),
|
|
369
|
+
...(row.turn_stopped_at === null ? {} : { turnStoppedAt: new Date(row.turn_stopped_at) }),
|
|
370
|
+
...(row.execution_claimed_at === null
|
|
371
|
+
? {}
|
|
372
|
+
: { executionClaimedAt: new Date(row.execution_claimed_at) }),
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Whether a run holds a call: an approval of its, or one it made for its turn, pending or decided
|
|
378
|
+
* and not reported yet. `holdsRun` and the paused finish of `runs.finish` both ask this.
|
|
379
|
+
*/
|
|
380
|
+
export async function pgHoldsRun(
|
|
381
|
+
sql: PgStoreClient,
|
|
382
|
+
projectId: string,
|
|
383
|
+
runId: string
|
|
384
|
+
): Promise<boolean> {
|
|
385
|
+
const [held] = await sql<{ id: string }[]>`
|
|
386
|
+
SELECT id FROM agent_approvals
|
|
387
|
+
WHERE project_id = ${projectId} AND (run_id = ${runId} OR caller_run_id = ${runId})
|
|
388
|
+
AND status IN ('pending', 'approved', 'denied') AND reported_in_message_id IS NULL
|
|
389
|
+
LIMIT 1
|
|
390
|
+
`
|
|
391
|
+
return held !== undefined
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function parseJson<T>(value: T | string): T {
|
|
395
|
+
return typeof value === "string" ? (JSON.parse(value) as T) : value
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function compareApprovals(left: AgentApprovalRecord, right: AgentApprovalRecord): number {
|
|
399
|
+
return left.createdAt.getTime() - right.createdAt.getTime() || left.id.localeCompare(right.id)
|
|
400
|
+
}
|
|
@@ -67,7 +67,10 @@ export class PgAgentContextCheckpointStore implements AgentContextCheckpointStor
|
|
|
67
67
|
|
|
68
68
|
const [existingForRunRow] = await tx<AgentContextCheckpointRow[]>`
|
|
69
69
|
SELECT * FROM agent_context_checkpoints
|
|
70
|
-
WHERE
|
|
70
|
+
WHERE
|
|
71
|
+
project_id = ${input.projectId}
|
|
72
|
+
AND thread_id = ${input.threadId}
|
|
73
|
+
AND created_by_run_id = ${input.createdByRunId}
|
|
71
74
|
`
|
|
72
75
|
if (existingForRunRow) {
|
|
73
76
|
const record = rowToContextCheckpointRecord(existingForRunRow)
|
|
@@ -177,6 +180,7 @@ export class PgAgentContextCheckpointStore implements AgentContextCheckpointStor
|
|
|
177
180
|
|
|
178
181
|
async getByRunIds(input: {
|
|
179
182
|
readonly projectId: string
|
|
183
|
+
readonly threadId: string
|
|
180
184
|
readonly runIds: readonly string[]
|
|
181
185
|
}): Promise<readonly AgentContextCheckpointRecord[]> {
|
|
182
186
|
const runIds = [...new Set(input.runIds)]
|
|
@@ -185,11 +189,11 @@ export class PgAgentContextCheckpointStore implements AgentContextCheckpointStor
|
|
|
185
189
|
const rows: AgentContextCheckpointRow[] = []
|
|
186
190
|
for (let offset = 0; offset < runIds.length; offset += PG_CHECKPOINT_RUN_ID_BATCH_SIZE) {
|
|
187
191
|
const batch = runIds.slice(offset, offset + PG_CHECKPOINT_RUN_ID_BATCH_SIZE)
|
|
188
|
-
const placeholders = batch.map((_, index) => `$${index +
|
|
192
|
+
const placeholders = batch.map((_, index) => `$${index + 3}`).join(", ")
|
|
189
193
|
rows.push(
|
|
190
194
|
...(await this.sql.unsafe<AgentContextCheckpointRow[]>(
|
|
191
|
-
`SELECT * FROM agent_context_checkpoints WHERE project_id = $1 AND created_by_run_id IN (${placeholders})`,
|
|
192
|
-
[input.projectId, ...batch]
|
|
195
|
+
`SELECT * FROM agent_context_checkpoints WHERE project_id = $1 AND thread_id = $2 AND created_by_run_id IN (${placeholders})`,
|
|
196
|
+
[input.projectId, input.threadId, ...batch]
|
|
193
197
|
))
|
|
194
198
|
)
|
|
195
199
|
}
|
package/src/agents/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type { PgAgentStorageOptions } from "./agent-storage"
|
|
2
2
|
export { PgAgentStorage } from "./agent-storage"
|
|
3
3
|
export { PgAgentContextCheckpointStore } from "./checkpoints"
|
|
4
|
+
export { PgAgentMemoryStore } from "./memory"
|
|
4
5
|
export { PgAgentMessageStore } from "./messages"
|
|
5
6
|
export { PgAgentRunStore } from "./runs"
|
|
6
7
|
export { PgAgentThreadStore } from "./threads"
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Principal } from "@sixb/core"
|
|
2
|
+
import {
|
|
3
|
+
agentMemoryConflicts,
|
|
4
|
+
agentMemoryHistoryCutoff,
|
|
5
|
+
assertSaveAgentMemoryInput,
|
|
6
|
+
} from "@sixb/core/internal/agent-run-storage-provider"
|
|
7
|
+
import type {
|
|
8
|
+
AgentMemoryRepoRecord,
|
|
9
|
+
AgentMemoryRevisionRecord,
|
|
10
|
+
AgentMemoryStore,
|
|
11
|
+
ListAgentMemoryRevisionsInput,
|
|
12
|
+
ListAgentMemoryRevisionsResult,
|
|
13
|
+
SaveAgentMemoryInput,
|
|
14
|
+
SaveAgentMemoryResult,
|
|
15
|
+
} from "@sixb/core/storage"
|
|
16
|
+
import { lockAdvisoryKeys, type PgStoreClient, runPgTransaction } from "../transactions"
|
|
17
|
+
|
|
18
|
+
interface RevisionRow {
|
|
19
|
+
readonly revision: number
|
|
20
|
+
readonly message: string
|
|
21
|
+
readonly author_principal_type: Principal["type"]
|
|
22
|
+
readonly author_principal_id: string
|
|
23
|
+
readonly source: string | null
|
|
24
|
+
readonly created_at: Date
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class PgAgentMemoryStore implements AgentMemoryStore {
|
|
28
|
+
constructor(private readonly sql: PgStoreClient) {}
|
|
29
|
+
|
|
30
|
+
async get(input: {
|
|
31
|
+
projectId: string
|
|
32
|
+
repoId: string
|
|
33
|
+
asOf?: Date
|
|
34
|
+
revision?: number
|
|
35
|
+
}): Promise<AgentMemoryRepoRecord> {
|
|
36
|
+
const asOf = input.asOf ?? null
|
|
37
|
+
const upTo = input.revision ?? null
|
|
38
|
+
// One statement, so the head and the files come from one snapshot.
|
|
39
|
+
const rows = await this.sql<{ head: number; path: string | null; contents: string | null }[]>`
|
|
40
|
+
WITH head AS (
|
|
41
|
+
SELECT COALESCE(MAX(revision), 0)::int AS revision FROM agent_memory_revisions
|
|
42
|
+
WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
|
|
43
|
+
AND (${asOf}::timestamptz IS NULL OR created_at <= ${asOf}::timestamptz)
|
|
44
|
+
AND (${upTo}::int IS NULL OR revision <= ${upTo}::int)
|
|
45
|
+
), latest AS (
|
|
46
|
+
SELECT DISTINCT ON (f.path) f.path, f.contents FROM agent_memory_files f, head
|
|
47
|
+
WHERE f.project_id = ${input.projectId} AND f.repo_id = ${input.repoId}
|
|
48
|
+
AND f.revision <= head.revision
|
|
49
|
+
ORDER BY f.path, f.revision DESC
|
|
50
|
+
)
|
|
51
|
+
SELECT head.revision AS head, latest.path, latest.contents
|
|
52
|
+
FROM head
|
|
53
|
+
LEFT JOIN latest ON latest.contents IS NOT NULL
|
|
54
|
+
ORDER BY latest.path
|
|
55
|
+
`
|
|
56
|
+
return {
|
|
57
|
+
revision: rows[0]?.head ?? 0,
|
|
58
|
+
files: rows.flatMap(({ path, contents }) =>
|
|
59
|
+
path === null || contents === null ? [] : [{ path, contents }]
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async save(input: SaveAgentMemoryInput): Promise<SaveAgentMemoryResult> {
|
|
65
|
+
assertSaveAgentMemoryInput(input, "SixbPg")
|
|
66
|
+
return runPgTransaction(this.sql, async (tx) => {
|
|
67
|
+
// A repo's first save has no row to lock, so saves to one repo take its advisory lock.
|
|
68
|
+
await lockAdvisoryKeys(tx, [`agent-memory:${input.projectId}:${input.repoId}`])
|
|
69
|
+
const [{ head } = { head: 0 }] = await tx<{ head: number }[]>`
|
|
70
|
+
SELECT COALESCE(MAX(revision), 0)::int AS head FROM agent_memory_revisions
|
|
71
|
+
WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
|
|
72
|
+
`
|
|
73
|
+
const paths = input.changes.map((change) => change.path)
|
|
74
|
+
const latest = await tx<{ path: string; revision: number; contents: string | null }[]>`
|
|
75
|
+
SELECT DISTINCT ON (path) path, revision, contents FROM agent_memory_files
|
|
76
|
+
WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
|
|
77
|
+
AND path IN ${tx(paths)}
|
|
78
|
+
ORDER BY path, revision DESC
|
|
79
|
+
`
|
|
80
|
+
const conflicts = agentMemoryConflicts(
|
|
81
|
+
input,
|
|
82
|
+
head,
|
|
83
|
+
new Map(latest.map((file) => [file.path, file]))
|
|
84
|
+
)
|
|
85
|
+
if (conflicts.length > 0) return { saved: false, revision: head, conflicts }
|
|
86
|
+
|
|
87
|
+
const revision = head + 1
|
|
88
|
+
const createdAt = input.createdAt ?? new Date()
|
|
89
|
+
await tx`
|
|
90
|
+
INSERT INTO agent_memory_revisions (
|
|
91
|
+
project_id, repo_id, revision, message, author_principal_type, author_principal_id,
|
|
92
|
+
source, created_at
|
|
93
|
+
) VALUES (
|
|
94
|
+
${input.projectId}, ${input.repoId}, ${revision}, ${input.message},
|
|
95
|
+
${input.author.type}, ${input.author.id}, ${input.source ?? null}, ${createdAt}
|
|
96
|
+
)
|
|
97
|
+
`
|
|
98
|
+
await tx`
|
|
99
|
+
INSERT INTO agent_memory_files (project_id, repo_id, path, revision, contents)
|
|
100
|
+
SELECT ${input.projectId}, ${input.repoId}, change.path, ${revision}, change.contents
|
|
101
|
+
FROM jsonb_to_recordset(${JSON.stringify(input.changes)}::text::jsonb)
|
|
102
|
+
AS change(path TEXT, contents TEXT)
|
|
103
|
+
`
|
|
104
|
+
await tx`
|
|
105
|
+
DELETE FROM agent_memory_files AS f
|
|
106
|
+
WHERE f.project_id = ${input.projectId} AND f.repo_id = ${input.repoId}
|
|
107
|
+
AND f.revision IN (
|
|
108
|
+
SELECT revision FROM agent_memory_revisions
|
|
109
|
+
WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
|
|
110
|
+
AND created_at < ${agentMemoryHistoryCutoff(createdAt)}
|
|
111
|
+
)
|
|
112
|
+
AND EXISTS (
|
|
113
|
+
SELECT 1 FROM agent_memory_files n
|
|
114
|
+
WHERE n.project_id = f.project_id AND n.repo_id = f.repo_id AND n.path = f.path
|
|
115
|
+
AND n.revision > f.revision
|
|
116
|
+
)
|
|
117
|
+
`
|
|
118
|
+
return { saved: true, revision }
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async listRevisions(
|
|
123
|
+
input: ListAgentMemoryRevisionsInput
|
|
124
|
+
): Promise<ListAgentMemoryRevisionsResult> {
|
|
125
|
+
return runPgTransaction(
|
|
126
|
+
this.sql,
|
|
127
|
+
async (tx) => {
|
|
128
|
+
const [counted] = await tx<{ total: number }[]>`
|
|
129
|
+
SELECT COUNT(*)::int AS total FROM agent_memory_revisions
|
|
130
|
+
WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
|
|
131
|
+
`
|
|
132
|
+
const offset = input.offset ?? 0
|
|
133
|
+
const rows = await tx<RevisionRow[]>`
|
|
134
|
+
SELECT revision, message, author_principal_type, author_principal_id, source, created_at
|
|
135
|
+
FROM agent_memory_revisions
|
|
136
|
+
WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
|
|
137
|
+
ORDER BY revision DESC
|
|
138
|
+
LIMIT ${input.limit ?? null} OFFSET ${offset}
|
|
139
|
+
`
|
|
140
|
+
const changes =
|
|
141
|
+
rows.length === 0
|
|
142
|
+
? []
|
|
143
|
+
: await tx<{ revision: number; path: string; deleted: boolean }[]>`
|
|
144
|
+
SELECT revision, path, contents IS NULL AS deleted FROM agent_memory_files
|
|
145
|
+
WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
|
|
146
|
+
AND revision IN ${tx(rows.map((row) => row.revision))}
|
|
147
|
+
ORDER BY path
|
|
148
|
+
`
|
|
149
|
+
const total = counted?.total ?? 0
|
|
150
|
+
const revisions = rows.map(
|
|
151
|
+
(row): AgentMemoryRevisionRecord => ({
|
|
152
|
+
revision: row.revision,
|
|
153
|
+
message: row.message,
|
|
154
|
+
author: { type: row.author_principal_type, id: row.author_principal_id },
|
|
155
|
+
...(row.source === null ? {} : { source: row.source }),
|
|
156
|
+
changes: changes
|
|
157
|
+
.filter((change) => change.revision === row.revision)
|
|
158
|
+
.map((change) => ({ path: change.path, deleted: change.deleted })),
|
|
159
|
+
createdAt: new Date(row.created_at),
|
|
160
|
+
})
|
|
161
|
+
)
|
|
162
|
+
return { revisions, hasMore: offset + rows.length < total, total }
|
|
163
|
+
},
|
|
164
|
+
{ isolation: "repeatableRead" }
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async delete(input: { projectId: string; repoId: string }): Promise<void> {
|
|
169
|
+
// File versions go with their revisions through ON DELETE CASCADE.
|
|
170
|
+
await this.sql`
|
|
171
|
+
DELETE FROM agent_memory_revisions
|
|
172
|
+
WHERE project_id = ${input.projectId} AND repo_id = ${input.repoId}
|
|
173
|
+
`
|
|
174
|
+
}
|
|
175
|
+
}
|