@lota-sdk/core 0.4.3 → 0.4.5

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.
@@ -1,4 +1,9 @@
1
- import type { ExecutionPlanToolResultData, PlanRunRecord, SerializableExecutionPlan } from '@lota-sdk/shared'
1
+ import type {
2
+ ExecutionPlanToolPlanSummary,
3
+ ExecutionPlanToolResultData,
4
+ PlanRunRecord,
5
+ SerializableExecutionPlan,
6
+ } from '@lota-sdk/shared'
2
7
 
3
8
  import type { RecordIdInput } from '../db/record-id'
4
9
  import { ensureRecordId } from '../db/record-id'
@@ -7,12 +12,25 @@ import { toDatabaseDateTime } from '../utils/date-time'
7
12
 
8
13
  export type PlanRunUpdate = Omit<
9
14
  Partial<PlanRunRecord>,
10
- 'currentNodeId' | 'waitingNodeId' | 'replacedRunId' | 'lastCheckpointId' | 'startedAt' | 'completedAt'
15
+ | 'sourceThreadId'
16
+ | 'createdByAgentId'
17
+ | 'currentNodeId'
18
+ | 'waitingNodeId'
19
+ | 'replacedRunId'
20
+ | 'lastCheckpointId'
21
+ | 'scheduledAt'
22
+ | 'scheduleId'
23
+ | 'startedAt'
24
+ | 'completedAt'
11
25
  > & {
26
+ sourceThreadId?: RecordIdInput | null
27
+ createdByAgentId?: string | null
12
28
  currentNodeId?: string | null
13
29
  waitingNodeId?: string | null
14
30
  replacedRunId?: RecordIdInput | null
15
31
  lastCheckpointId?: RecordIdInput | null
32
+ scheduledAt?: string | Date | null
33
+ scheduleId?: RecordIdInput | null
16
34
  startedAt?: string | Date | null
17
35
  completedAt?: string | Date | null
18
36
  }
@@ -22,7 +40,21 @@ export function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
22
40
  planSpecId: ensureRecordId(run.planSpecId, TABLES.PLAN_SPEC),
23
41
  organizationId: ensureRecordId(run.organizationId, TABLES.ORGANIZATION),
24
42
  threadId: ensureRecordId(run.threadId, TABLES.THREAD),
43
+ ...(patch.sourceThreadId === null
44
+ ? {}
45
+ : patch.sourceThreadId !== undefined
46
+ ? { sourceThreadId: ensureRecordId(patch.sourceThreadId, TABLES.THREAD) }
47
+ : run.sourceThreadId
48
+ ? { sourceThreadId: ensureRecordId(run.sourceThreadId, TABLES.THREAD) }
49
+ : {}),
25
50
  leadAgentId: patch.leadAgentId ?? run.leadAgentId,
51
+ ...(patch.createdByAgentId === null
52
+ ? {}
53
+ : patch.createdByAgentId !== undefined
54
+ ? { createdByAgentId: patch.createdByAgentId }
55
+ : run.createdByAgentId
56
+ ? { createdByAgentId: run.createdByAgentId }
57
+ : {}),
26
58
  status: patch.status ?? run.status,
27
59
  ...(patch.currentNodeId === null
28
60
  ? {}
@@ -54,6 +86,20 @@ export function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
54
86
  : run.lastCheckpointId
55
87
  ? { lastCheckpointId: ensureRecordId(run.lastCheckpointId, TABLES.PLAN_CHECKPOINT) }
56
88
  : {}),
89
+ ...(patch.scheduledAt === null
90
+ ? {}
91
+ : patch.scheduledAt !== undefined
92
+ ? { scheduledAt: toDatabaseDateTime(patch.scheduledAt) }
93
+ : run.scheduledAt
94
+ ? { scheduledAt: toDatabaseDateTime(run.scheduledAt) }
95
+ : {}),
96
+ ...(patch.scheduleId === null
97
+ ? {}
98
+ : patch.scheduleId
99
+ ? { scheduleId: ensureRecordId(patch.scheduleId, TABLES.PLAN_SCHEDULE) }
100
+ : run.scheduleId
101
+ ? { scheduleId: ensureRecordId(run.scheduleId, TABLES.PLAN_SCHEDULE) }
102
+ : {}),
57
103
  ...(patch.startedAt === null
58
104
  ? {}
59
105
  : patch.startedAt !== undefined
@@ -71,13 +117,17 @@ export function toRunData(run: PlanRunRecord, patch: PlanRunUpdate) {
71
117
  }
72
118
  }
73
119
 
74
- function toSlimPlanSummary(plan: SerializableExecutionPlan): NonNullable<ExecutionPlanToolResultData['plan']> {
120
+ export function toToolPlanSummary(plan: SerializableExecutionPlan): ExecutionPlanToolPlanSummary {
75
121
  const completed = plan.progress.completed + plan.progress.partial
76
122
  return {
77
123
  runId: plan.runId,
124
+ threadId: plan.threadId,
78
125
  title: plan.title,
79
126
  objective: plan.objective,
80
127
  status: plan.status,
128
+ leadAgentId: plan.leadAgentId,
129
+ ...(plan.sourceThreadId ? { sourceThreadId: plan.sourceThreadId } : {}),
130
+ ...(plan.createdByAgentId ? { createdByAgentId: plan.createdByAgentId } : {}),
81
131
  progress: { completed, total: plan.progress.total },
82
132
  nodes: plan.nodes.map((node) => ({
83
133
  id: node.id,
@@ -85,7 +135,23 @@ function toSlimPlanSummary(plan: SerializableExecutionPlan): NonNullable<Executi
85
135
  type: node.type,
86
136
  status: node.status,
87
137
  ownerRef: node.owner.ref,
138
+ objective: node.objective,
139
+ ownerType: node.owner.executorType,
140
+ artifactCount: plan.artifacts.filter((artifact) => artifact.nodeId === node.id).length,
141
+ approvalId:
142
+ plan.approvals.find((approval) => approval.nodeId === node.id && approval.status === 'pending')?.id ?? null,
143
+ approvalStatus:
144
+ plan.approvals.find((approval) => approval.nodeId === node.id && approval.status === 'pending')?.status ?? null,
145
+ blockedReason: node.blockedReason ?? null,
146
+ latestNotes: node.latestNotes ?? null,
147
+ startedAt: node.startedAt ?? null,
148
+ completedAt: node.completedAt ?? null,
149
+ readyAt: node.readyAt ?? null,
150
+ deliverableNames: node.deliverables.map((deliverable) => deliverable.name),
151
+ upstreamNodeIds: [...node.upstreamNodeIds],
152
+ downstreamNodeIds: [...node.downstreamNodeIds],
88
153
  })),
154
+ edges: [...plan.edges],
89
155
  activeNodeIds: plan.activeNodeIds,
90
156
  readyNodeIds: plan.readyNodeIds,
91
157
  }
@@ -96,7 +162,7 @@ export function buildExecutionPlanToolResult(params: {
96
162
  plan: SerializableExecutionPlan | null
97
163
  message: string
98
164
  }): ExecutionPlanToolResultData {
99
- const slim = params.plan ? toSlimPlanSummary(params.plan) : null
165
+ const slim = params.plan ? toToolPlanSummary(params.plan) : null
100
166
  return {
101
167
  action: params.action,
102
168
  message: params.message,
@@ -37,7 +37,7 @@ import { databaseService } from '../db/service'
37
37
  import { TABLES } from '../db/tables'
38
38
  import { toIsoDateTimeString, toOptionalIsoDateTimeString } from '../utils/date-time'
39
39
 
40
- const ACTIVE_RUN_STATUSES = new Set(['running', 'awaiting-human', 'blocked'])
40
+ const ACTIVE_RUN_STATUSES = new Set(['pending-approval', 'running', 'awaiting-human', 'blocked'])
41
41
 
42
42
  function buildProgress(nodeRuns: PlanNodeRunRecord[]) {
43
43
  const counts = nodeRuns.reduce(
@@ -239,6 +239,31 @@ class PlanRunService {
239
239
  return runs.filter((run) => ACTIVE_RUN_STATUSES.has(run.status))
240
240
  }
241
241
 
242
+ async getRunsCreatedInContext(params: {
243
+ organizationId: RecordIdInput
244
+ sourceThreadId?: RecordIdInput
245
+ createdByAgentId?: string
246
+ statuses?: ReadonlyArray<PlanRunRecord['status']>
247
+ }): Promise<PlanRunRecord[]> {
248
+ const filter: Record<string, unknown> = {
249
+ organizationId: ensureRecordId(params.organizationId, TABLES.ORGANIZATION),
250
+ }
251
+ if (params.sourceThreadId) {
252
+ filter.sourceThreadId = ensureRecordId(params.sourceThreadId, TABLES.THREAD)
253
+ }
254
+ if (params.createdByAgentId) {
255
+ filter.createdByAgentId = params.createdByAgentId
256
+ }
257
+
258
+ const runs = await databaseService.findMany(TABLES.PLAN_RUN, filter, PlanRunSchema, {
259
+ orderBy: 'updatedAt',
260
+ orderDir: 'DESC',
261
+ })
262
+
263
+ const statuses = params.statuses ? new Set(params.statuses) : ACTIVE_RUN_STATUSES
264
+ return runs.filter((run) => statuses.has(run.status))
265
+ }
266
+
242
267
  async listNodeRuns(runId: RecordIdInput): Promise<PlanNodeRunRecord[]> {
243
268
  return databaseService.findMany(
244
269
  TABLES.PLAN_NODE_RUN,
@@ -435,12 +460,14 @@ class PlanRunService {
435
460
  specId: recordIdToString(spec.id, TABLES.PLAN_SPEC),
436
461
  runId: recordIdToString(run.id, TABLES.PLAN_RUN),
437
462
  threadId: recordIdToString(run.threadId, TABLES.THREAD),
463
+ sourceThreadId: run.sourceThreadId ? recordIdToString(run.sourceThreadId, TABLES.THREAD) : undefined,
438
464
  organizationId: recordIdToString(run.organizationId, TABLES.ORGANIZATION),
439
465
  title: spec.title,
440
466
  objective: spec.objective,
441
467
  version: spec.version,
442
468
  status: run.status,
443
469
  leadAgentId: run.leadAgentId,
470
+ createdByAgentId: run.createdByAgentId,
444
471
  defaultExecutionVisibility: spec.defaultExecutionVisibility,
445
472
  executionMode: spec.executionMode,
446
473
  schemaRegistry: slim ? {} : structuredClone(spec.schemaRegistry),
@@ -1,6 +1,7 @@
1
1
  import { PlanCycleRecordSchema, PlanScheduleRecordSchema } from '@lota-sdk/shared'
2
2
  import type { PlanScheduleRecord, PlanScheduleSpec } from '@lota-sdk/shared'
3
3
  import { CronExpressionParser } from 'cron-parser'
4
+ import { BoundQuery } from 'surrealdb'
4
5
 
5
6
  import type { RecordIdInput } from '../db/record-id'
6
7
  import { ensureRecordId, recordIdToString } from '../db/record-id'
@@ -156,10 +157,9 @@ class PlanSchedulerService {
156
157
  /** Re-enqueue BullMQ jobs for all active schedules. Called once at worker startup. */
157
158
  async recoverActiveSchedules(): Promise<void> {
158
159
  const activeSchedules = await databaseService.queryMany(
159
- {
160
- query: `SELECT * FROM ${TABLES.PLAN_SCHEDULE} WHERE status = $status ORDER BY nextFireAt ASC`,
161
- bindings: { status: 'active' },
162
- },
160
+ new BoundQuery(`SELECT * FROM ${TABLES.PLAN_SCHEDULE} WHERE status = $status ORDER BY nextFireAt ASC`, {
161
+ status: 'active',
162
+ }),
163
163
  PlanScheduleRecordSchema,
164
164
  )
165
165
 
@@ -143,7 +143,10 @@ class PlanTemplateService {
143
143
  templateId: RecordIdInput
144
144
  organizationId: RecordIdInput
145
145
  threadId: RecordIdInput
146
+ sourceThreadId?: RecordIdInput
146
147
  leadAgentId: string
148
+ createdByAgentId?: string
149
+ requireApproval?: boolean
147
150
  overrides?: Partial<PlanDraft>
148
151
  carryForwardArtifacts?: PlanArtifactRecord[]
149
152
  }): Promise<ExecutionPlanToolResultData> {
@@ -162,7 +165,10 @@ class PlanTemplateService {
162
165
  return executionPlanService.createPlan({
163
166
  organizationId: params.organizationId,
164
167
  threadId: params.threadId,
168
+ sourceThreadId: params.sourceThreadId,
165
169
  leadAgentId: params.leadAgentId,
170
+ createdByAgentId: params.createdByAgentId,
171
+ requireApproval: params.requireApproval,
166
172
  input: draft,
167
173
  })
168
174
  }
@@ -56,10 +56,16 @@ export function createExecutionPlanTool(params: {
56
56
  case 'create': {
57
57
  const draft = extractAgentPlanDraft(parsed)
58
58
  params.validateInlinePlan?.(draft)
59
+ const targetThreadId = parsed.targetThreadId ?? params.threadId
60
+ const isCrossThreadTarget =
61
+ recordIdToString(targetThreadId, TABLES.THREAD) !== recordIdToString(params.threadId, TABLES.THREAD)
59
62
  result = await resolvedEpService.createPlan({
60
63
  organizationId: params.orgId,
61
- threadId: parsed.targetThreadId ?? params.threadId,
64
+ threadId: targetThreadId,
65
+ ...(isCrossThreadTarget ? { sourceThreadId: params.threadId } : {}),
62
66
  leadAgentId: params.agentId,
67
+ createdByAgentId: params.agentId,
68
+ requireApproval: parsed.requireApproval ?? isCrossThreadTarget,
63
69
  input: draft,
64
70
  })
65
71
  break
@@ -101,11 +107,15 @@ export function createExecutionPlanTool(params: {
101
107
  const created = await resolvedEpService.createPlan({
102
108
  organizationId: params.orgId,
103
109
  threadId: targetThread.id,
110
+ sourceThreadId: params.threadId,
104
111
  leadAgentId: params.agentId,
112
+ createdByAgentId: params.agentId,
113
+ requireApproval: parsed.requireApproval ?? true,
105
114
  input: draft,
106
115
  })
107
116
  result = {
108
117
  ...created,
118
+ runId: created.plan?.runId ?? '',
109
119
  threadId: targetThread.id,
110
120
  threadTitle: targetThread.title,
111
121
  createdThread,
@@ -125,7 +135,8 @@ export function createExecutionPlanTool(params: {
125
135
  organizationId: params.orgId,
126
136
  threadId: params.threadId,
127
137
  leadAgentId: params.agentId,
128
- input: { runId: parsed.runId, reason: parsed.reason, ...draft },
138
+ createdByAgentId: params.agentId,
139
+ input: { runId: parsed.runId, reason: parsed.reason, requireApproval: parsed.requireApproval, ...draft },
129
140
  })
130
141
  break
131
142
  }
@@ -1,6 +1,7 @@
1
1
  export * from './execution-plan.tool'
2
2
  export * from './fetch-webpage.tool'
3
3
  export * from './memory-block.tool'
4
+ export * from './plan-approval.tool'
4
5
  export * from './read-file-parts.tool'
5
6
  export * from './remember-memory.tool'
6
7
  export * from './research-topic.tool'
@@ -0,0 +1,66 @@
1
+ import { PlanApprovalArgsSchema, PlanApprovalToolResultDataSchema } from '@lota-sdk/shared'
2
+ import { tool } from 'ai'
3
+
4
+ import type { RecordIdRef } from '../db/record-id'
5
+ import { executionPlanService } from '../services/execution-plan.service'
6
+ import { toToolPlanSummary } from '../services/plan-run-data'
7
+
8
+ type PlanApprovalExecutionPlanService = Pick<typeof executionPlanService, 'approvePlan' | 'rejectPlan'>
9
+
10
+ export function createPlanApprovalTool(params: {
11
+ orgId: RecordIdRef
12
+ threadId: RecordIdRef
13
+ actorId: string
14
+ executionPlanService?: PlanApprovalExecutionPlanService
15
+ }) {
16
+ const resolvedExecutionPlanService = params.executionPlanService ?? executionPlanService
17
+
18
+ return tool({
19
+ description:
20
+ 'Approve, reject, or request changes to a plan that is pending approval. Use approve to start execution, reject to abort it, or modify to request a revised plan.',
21
+ inputSchema: PlanApprovalArgsSchema,
22
+ outputSchema: PlanApprovalToolResultDataSchema,
23
+ execute: async (input) => {
24
+ switch (input.action) {
25
+ case 'approve': {
26
+ const plan = await resolvedExecutionPlanService.approvePlan({
27
+ organizationId: params.orgId,
28
+ threadId: params.threadId,
29
+ runId: input.runId,
30
+ emittedBy: params.actorId,
31
+ })
32
+ const toolPlan = toToolPlanSummary(plan)
33
+ return {
34
+ action: 'approved',
35
+ message: `Approved execution plan "${plan.title}" and started it.`,
36
+ hasPlan: true,
37
+ status: toolPlan.status,
38
+ plan: toolPlan,
39
+ }
40
+ }
41
+ case 'reject':
42
+ case 'modify': {
43
+ const plan = await resolvedExecutionPlanService.rejectPlan({
44
+ organizationId: params.orgId,
45
+ threadId: params.threadId,
46
+ runId: input.runId,
47
+ emittedBy: params.actorId,
48
+ reason: input.reason,
49
+ resolution: input.action === 'modify' ? 'changes-requested' : 'rejected',
50
+ })
51
+ const toolPlan = toToolPlanSummary(plan)
52
+ return {
53
+ action: input.action === 'modify' ? 'changes-requested' : 'rejected',
54
+ message:
55
+ input.action === 'modify'
56
+ ? `Requested changes for execution plan "${plan.title}".`
57
+ : `Rejected execution plan "${plan.title}".`,
58
+ hasPlan: true,
59
+ status: toolPlan.status,
60
+ plan: toolPlan,
61
+ }
62
+ }
63
+ }
64
+ },
65
+ })
66
+ }
@@ -5,17 +5,11 @@ import type { Job, Worker } from 'bullmq'
5
5
 
6
6
  import { chatLogger } from '../config/logger'
7
7
  import { queueJobService } from '../services/queue-job.service'
8
- import { truncateText } from '../utils/string'
9
-
10
8
  export const DEFAULT_JOB_RETENTION = { removeOnComplete: true, removeOnFail: { age: 24 * 60 * 60, count: 200 } }
11
9
  export const LOW_JOB_RETENTION = { removeOnComplete: true, removeOnFail: { age: 6 * 60 * 60, count: 50 } }
12
10
  export const LONG_JOB_LOCK_DURATION_MS = 600_000
13
11
 
14
12
  const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10_000
15
- const MAX_TRACE_STRING_CHARS = 2_000
16
- const MAX_TRACE_ARRAY_ITEMS = 12
17
- const MAX_TRACE_OBJECT_KEYS = 24
18
- const MAX_TRACE_DEPTH = 4
19
13
 
20
14
  export function getWorkerPath(workerName: string): string {
21
15
  return fileURLToPath(new URL(path.join('.', workerName), import.meta.url))
@@ -35,68 +29,6 @@ interface TracedWorkerJobLike {
35
29
  timestamp?: number
36
30
  }
37
31
 
38
- function truncateTraceString(value: string, maxChars = MAX_TRACE_STRING_CHARS): string {
39
- return truncateText(value, maxChars)
40
- }
41
-
42
- function normalizeTraceValue(value: unknown, depth = 0): unknown {
43
- if (value === null || value === undefined) return value
44
- if (typeof value === 'string') return truncateTraceString(value)
45
- if (typeof value === 'number' || typeof value === 'boolean') return value
46
- if (typeof value === 'bigint') return value.toString()
47
- if (typeof value === 'symbol') return value.description ? `Symbol(${value.description})` : 'Symbol()'
48
- if (typeof value === 'function') return value.name ? `[function ${value.name}]` : '[function anonymous]'
49
- if (value instanceof Date) return value.toISOString()
50
-
51
- if (depth >= MAX_TRACE_DEPTH) {
52
- if (Array.isArray(value)) return `[array(${value.length})]`
53
- return '[object]'
54
- }
55
-
56
- if (Array.isArray(value)) {
57
- return value.slice(0, MAX_TRACE_ARRAY_ITEMS).map((item) => normalizeTraceValue(item, depth + 1))
58
- }
59
-
60
- if (!(value instanceof Date) && typeof value === 'object') {
61
- const record = value as Record<string, unknown>
62
- return Object.fromEntries(
63
- Object.entries(record)
64
- .slice(0, MAX_TRACE_OBJECT_KEYS)
65
- .map(([key, entryValue]) => [key, normalizeTraceValue(entryValue, depth + 1)]),
66
- )
67
- }
68
-
69
- return '[unknown]'
70
- }
71
-
72
- function serializeTraceValue(value: unknown): string {
73
- const serialized = JSON.stringify(normalizeTraceValue(value))
74
- return truncateTraceString(serialized || 'null')
75
- }
76
-
77
- function traceTextValue(value: unknown): string {
78
- if (typeof value === 'string') return truncateTraceString(value)
79
- if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
80
- return truncateTraceString(String(value))
81
- }
82
- if (value instanceof Date) return value.toISOString()
83
- return serializeTraceValue(value)
84
- }
85
-
86
- export function buildWorkerObservationMetadata(
87
- queueName: string,
88
- job: { id?: unknown; name: string; attemptsMade: number | null | undefined },
89
- ): Record<string, string> {
90
- return {
91
- queue: traceTextValue(queueName),
92
- job_name: traceTextValue(job.name),
93
- ...(job.id !== undefined ? { job_id: traceTextValue(job.id) } : {}),
94
- ...(job.attemptsMade !== null && job.attemptsMade !== undefined
95
- ? { attempts_made: traceTextValue(job.attemptsMade) }
96
- : {}),
97
- }
98
- }
99
-
100
32
  export const attachWorkerEvents = (worker: Worker, name: string, logger: typeof chatLogger = chatLogger) => {
101
33
  worker.on('ready', () => {
102
34
  logger.info`${name} worker ready`