@lota-sdk/core 0.1.35 → 0.1.37
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/infrastructure/schema/00_workstream.surql +1 -0
- package/package.json +2 -2
- package/src/config/agent-defaults.ts +8 -0
- package/src/create-runtime.ts +2 -0
- package/src/runtime/runtime-config.ts +4 -0
- package/src/services/global-orchestrator.service.ts +35 -3
- package/src/services/plan-agent-query.service.ts +28 -0
- package/src/services/workstream-turn-preparation.service.ts +108 -130
- package/src/services/workstream.service.ts +12 -2
- package/src/system-agents/index.ts +1 -0
- package/src/system-agents/workstream-manager.agent.ts +152 -0
|
@@ -19,6 +19,7 @@ DEFINE FIELD IF NOT EXISTS lastCompactedMessageId ON TABLE workstream TYPE optio
|
|
|
19
19
|
DEFINE FIELD IF NOT EXISTS nameGenerated ON TABLE workstream TYPE bool DEFAULT false;
|
|
20
20
|
DEFINE FIELD IF NOT EXISTS isCompacting ON TABLE workstream TYPE bool DEFAULT false;
|
|
21
21
|
DEFINE FIELD IF NOT EXISTS state ON TABLE workstream TYPE option<object> FLEXIBLE;
|
|
22
|
+
DEFINE FIELD IF NOT EXISTS members ON TABLE workstream TYPE option<array<string>> DEFAULT [];
|
|
22
23
|
DEFINE FIELD IF NOT EXISTS turnCount ON TABLE workstream TYPE int DEFAULT 0;
|
|
23
24
|
|
|
24
25
|
DEFINE INDEX IF NOT EXISTS workstreamOrgIdx ON TABLE workstream COLUMNS organizationId;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lota-sdk/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@chat-adapter/slack": "^4.23.0",
|
|
33
33
|
"@chat-adapter/state-ioredis": "^4.23.0",
|
|
34
34
|
"@logtape/logtape": "^2.0.5",
|
|
35
|
-
"@lota-sdk/shared": "0.1.
|
|
35
|
+
"@lota-sdk/shared": "0.1.37",
|
|
36
36
|
"@mendable/firecrawl-js": "^4.18.0",
|
|
37
37
|
"@surrealdb/node": "^3.0.3",
|
|
38
38
|
"ai": "^6.0.141",
|
|
@@ -11,12 +11,15 @@ function defaultGetAgentRuntimeConfig(): Record<string, never> {
|
|
|
11
11
|
// Agent configuration — these are defaults that consumers override via createLotaRuntime config
|
|
12
12
|
export let agentDisplayNames: Record<string, string> = {}
|
|
13
13
|
export let agentShortDisplayNames: Record<string, string> = {}
|
|
14
|
+
export let agentDescriptions: Record<string, string> = {}
|
|
15
|
+
export let managerModelId: string | undefined = undefined
|
|
14
16
|
export let agentRoster: readonly string[] = []
|
|
15
17
|
export let leadAgentId = ''
|
|
16
18
|
export let teamConsultParticipants: readonly string[] = []
|
|
17
19
|
|
|
18
20
|
export interface CoreWorkstreamProfile {
|
|
19
21
|
config: { coreType: string; agentId: string; title: string }
|
|
22
|
+
members: readonly string[]
|
|
20
23
|
tools: readonly string[]
|
|
21
24
|
skills: readonly string[]
|
|
22
25
|
instructions: string
|
|
@@ -24,6 +27,7 @@ export interface CoreWorkstreamProfile {
|
|
|
24
27
|
|
|
25
28
|
export let getCoreWorkstreamProfile: (coreType: string) => CoreWorkstreamProfile = (_coreType) => ({
|
|
26
29
|
config: { coreType: _coreType, agentId: '', title: '' },
|
|
30
|
+
members: [],
|
|
27
31
|
tools: [],
|
|
28
32
|
skills: [],
|
|
29
33
|
instructions: '',
|
|
@@ -34,6 +38,8 @@ export function configureAgents(config: {
|
|
|
34
38
|
leadAgentId: string
|
|
35
39
|
displayNames: Record<string, string>
|
|
36
40
|
shortDisplayNames?: Record<string, string>
|
|
41
|
+
descriptions?: Record<string, string>
|
|
42
|
+
managerModelId?: string
|
|
37
43
|
teamConsultParticipants: readonly string[]
|
|
38
44
|
getCoreWorkstreamProfile?: (coreType: string) => CoreWorkstreamProfile
|
|
39
45
|
}): void {
|
|
@@ -45,6 +51,8 @@ export function configureAgents(config: {
|
|
|
45
51
|
leadAgentId = config.leadAgentId
|
|
46
52
|
agentDisplayNames = config.displayNames
|
|
47
53
|
agentShortDisplayNames = config.shortDisplayNames ?? {}
|
|
54
|
+
agentDescriptions = config.descriptions ?? {}
|
|
55
|
+
managerModelId = config.managerModelId
|
|
48
56
|
teamConsultParticipants = config.teamConsultParticipants
|
|
49
57
|
if (config.getCoreWorkstreamProfile) {
|
|
50
58
|
getCoreWorkstreamProfile = config.getCoreWorkstreamProfile
|
package/src/create-runtime.ts
CHANGED
|
@@ -250,6 +250,8 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
|
|
|
250
250
|
leadAgentId: runtimeConfig.agents.leadAgentId,
|
|
251
251
|
displayNames: agentDisplayNames,
|
|
252
252
|
shortDisplayNames: runtimeConfig.agents.shortDisplayNames,
|
|
253
|
+
descriptions: runtimeConfig.agents.descriptions,
|
|
254
|
+
managerModelId: runtimeConfig.agents.managerModelId,
|
|
253
255
|
teamConsultParticipants: runtimeConfig.agents.teamConsultParticipants,
|
|
254
256
|
getCoreWorkstreamProfile: runtimeConfig.agents.getCoreWorkstreamProfile,
|
|
255
257
|
})
|
|
@@ -173,6 +173,10 @@ const agentsConfigSchema = z
|
|
|
173
173
|
shortDisplayNames: z
|
|
174
174
|
.custom<Record<string, string>>(isStringRecord, { error: 'agents.shortDisplayNames must be a string record' })
|
|
175
175
|
.optional(),
|
|
176
|
+
descriptions: z
|
|
177
|
+
.custom<Record<string, string>>(isStringRecord, { error: 'agents.descriptions must be a string record' })
|
|
178
|
+
.optional(),
|
|
179
|
+
managerModelId: z.string().trim().min(1).optional(),
|
|
176
180
|
teamConsultParticipants: z.array(z.string().trim().min(1)),
|
|
177
181
|
getCoreWorkstreamProfile: z
|
|
178
182
|
.custom<(coreType: string) => CoreWorkstreamProfile>(isFunction, {
|
|
@@ -3,6 +3,7 @@ import type { ConvergenceState, PlanFailureClass } from '@lota-sdk/shared'
|
|
|
3
3
|
import { serverLogger } from '../config/logger'
|
|
4
4
|
import { recordIdToString } from '../db/record-id'
|
|
5
5
|
import { TABLES } from '../db/tables'
|
|
6
|
+
import { shouldPlanNodeUseVisibleTurn } from '../runtime/execution-plan-visibility'
|
|
6
7
|
|
|
7
8
|
function classifyDispatchFailure(ownerType: string, error: unknown): PlanFailureClass {
|
|
8
9
|
const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
|
|
@@ -80,17 +81,48 @@ class GlobalOrchestratorService {
|
|
|
80
81
|
})
|
|
81
82
|
if (readyNodes.length === 0) break
|
|
82
83
|
|
|
84
|
+
// Split into silent (dispatch now) and visible (enqueue heartbeat wake)
|
|
85
|
+
const silentNodes = readyNodes.filter((nr) => {
|
|
86
|
+
const ns = nodeSpecs.find((s) => s.nodeId === nr.nodeId)
|
|
87
|
+
return ns && !shouldPlanNodeUseVisibleTurn(spec, ns)
|
|
88
|
+
})
|
|
89
|
+
const visibleNodes = readyNodes.filter((nr) => {
|
|
90
|
+
const ns = nodeSpecs.find((s) => s.nodeId === nr.nodeId)
|
|
91
|
+
return ns && shouldPlanNodeUseVisibleTurn(spec, ns)
|
|
92
|
+
})
|
|
93
|
+
|
|
83
94
|
// Transition all ready nodes to 'running' BEFORE dispatching
|
|
84
95
|
for (const nodeRun of readyNodes) {
|
|
85
96
|
await planExecutorService.transitionNodeToRunning({ runId: params.runId, nodeId: nodeRun.nodeId })
|
|
86
97
|
}
|
|
87
98
|
|
|
99
|
+
// Enqueue heartbeat wakes for visible agent nodes — they need a streaming turn
|
|
100
|
+
if (visibleNodes.length > 0) {
|
|
101
|
+
const { enqueuePlanAgentHeartbeatWake } = await import('../queues/plan-agent-heartbeat.queue')
|
|
102
|
+
const updatedRunForWake = await planRunService.getRunById(params.runId)
|
|
103
|
+
for (const nodeRun of visibleNodes) {
|
|
104
|
+
const ns = nodeSpecs.find((s) => s.nodeId === nodeRun.nodeId)
|
|
105
|
+
if (!ns || ns.owner.executorType !== 'agent') continue
|
|
106
|
+
await enqueuePlanAgentHeartbeatWake({
|
|
107
|
+
organizationId: recordIdToString(updatedRunForWake.organizationId, TABLES.ORGANIZATION),
|
|
108
|
+
workstreamId: recordIdToString(updatedRunForWake.workstreamId, TABLES.WORKSTREAM),
|
|
109
|
+
runId: recordIdToString(updatedRunForWake.id, TABLES.PLAN_RUN),
|
|
110
|
+
nodeId: nodeRun.nodeId,
|
|
111
|
+
agentId: ns.owner.ref,
|
|
112
|
+
reason: 'graph-full-visible',
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// If no silent nodes to dispatch, break and let heartbeat handle visible ones
|
|
118
|
+
if (silentNodes.length === 0) break
|
|
119
|
+
|
|
88
120
|
// Re-fetch run after transitions for accurate state in dispatch context
|
|
89
121
|
const updatedRun = await planRunService.getRunById(params.runId)
|
|
90
122
|
|
|
91
|
-
// Dispatch
|
|
123
|
+
// Dispatch silent nodes in parallel with LINEAR mode override (prevents recursion)
|
|
92
124
|
const results = await Promise.allSettled(
|
|
93
|
-
|
|
125
|
+
silentNodes.map(async (nodeRun) => {
|
|
94
126
|
const nodeSpecRecord = nodeSpecs.find((ns) => ns.nodeId === nodeRun.nodeId)
|
|
95
127
|
if (!nodeSpecRecord) {
|
|
96
128
|
throw new Error(`Node spec not found for node "${nodeRun.nodeId}".`)
|
|
@@ -114,7 +146,7 @@ class GlobalOrchestratorService {
|
|
|
114
146
|
// Submit results sequentially (each triggers syncRunGraph internally)
|
|
115
147
|
for (let i = 0; i < results.length; i++) {
|
|
116
148
|
const settled = results[i]
|
|
117
|
-
const nodeRun =
|
|
149
|
+
const nodeRun = silentNodes[i]
|
|
118
150
|
const nodeSpecRecord = nodeSpecs.find((ns) => ns.nodeId === nodeRun.nodeId)
|
|
119
151
|
|
|
120
152
|
if (settled.status === 'fulfilled') {
|
|
@@ -73,6 +73,34 @@ class PlanAgentQueryService {
|
|
|
73
73
|
|
|
74
74
|
for (const run of runs) {
|
|
75
75
|
const spec = await planRunService.getPlanSpecById(run.planSpecId)
|
|
76
|
+
|
|
77
|
+
// Graph-full plans can have multiple visible agent nodes running in parallel
|
|
78
|
+
if (spec.executionMode === 'graph-full') {
|
|
79
|
+
const [nodeSpecs, nodeRuns] = await Promise.all([
|
|
80
|
+
planRunService.listNodeSpecs(spec.id),
|
|
81
|
+
planRunService.listNodeRuns(run.id),
|
|
82
|
+
])
|
|
83
|
+
for (const nodeRun of nodeRuns) {
|
|
84
|
+
if (!ACTIONABLE_NODE_STATUSES.has(nodeRun.status)) continue
|
|
85
|
+
const nodeSpec = nodeSpecs.find((ns) => ns.nodeId === nodeRun.nodeId)
|
|
86
|
+
if (!nodeSpec) continue
|
|
87
|
+
const visibleTarget = isVisibleAgentNode({ nodeSpec, spec })
|
|
88
|
+
if (!visibleTarget) continue
|
|
89
|
+
if (params.agentId && params.agentId !== visibleTarget.agentId) continue
|
|
90
|
+
actionable.push({
|
|
91
|
+
organizationId: recordIdToString(run.organizationId, TABLES.ORGANIZATION),
|
|
92
|
+
workstreamId: recordIdToString(run.workstreamId, TABLES.WORKSTREAM),
|
|
93
|
+
runId: recordIdToString(run.id, TABLES.PLAN_RUN),
|
|
94
|
+
nodeId: nodeSpec.nodeId,
|
|
95
|
+
agentId: visibleTarget.agentId,
|
|
96
|
+
status: nodeRun.status as 'ready' | 'running',
|
|
97
|
+
visibility: visibleTarget.visibility,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Linear plans: only the current node is actionable
|
|
76
104
|
const currentNodeId = run.currentNodeId
|
|
77
105
|
if (!currentNodeId) {
|
|
78
106
|
continue
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
WORKSTREAM,
|
|
3
|
-
baseChatMessageSchema,
|
|
4
|
-
CONSULT_SPECIALIST_TOOL_NAME,
|
|
5
|
-
CONSULT_TEAM_TOOL_NAME,
|
|
6
|
-
ConsultSpecialistArgsSchema,
|
|
7
3
|
dataPartsSchemas,
|
|
8
4
|
messageMetadataSchema,
|
|
9
5
|
PlanNodeResultSubmissionSchema,
|
|
@@ -11,7 +7,7 @@ import {
|
|
|
11
7
|
toTimestamp,
|
|
12
8
|
withMessageCreatedAt,
|
|
13
9
|
} from '@lota-sdk/shared'
|
|
14
|
-
import type { ChatMessage,
|
|
10
|
+
import type { ChatMessage, MessageMetadata, PlanNodeSpecRecord } from '@lota-sdk/shared'
|
|
15
11
|
import { convertToModelMessages, stepCountIs, tool as createTool, validateUIMessages } from 'ai'
|
|
16
12
|
import type { PrepareStepFunction, StopCondition, ToolLoopAgent, ToolSet, UIMessageStreamWriter } from 'ai'
|
|
17
13
|
|
|
@@ -32,11 +28,7 @@ import { TABLES } from '../db/tables'
|
|
|
32
28
|
import { enqueueContextCompaction } from '../queues/context-compaction.queue'
|
|
33
29
|
import { enqueueWorkstreamTitleGeneration } from '../queues/title-generation.queue'
|
|
34
30
|
import { OWNERSHIP_DISPATCH_BLOCKED_TOOL_NAMES } from '../runtime/agent-runtime-policy'
|
|
35
|
-
import {
|
|
36
|
-
buildSpecialistTaskMessage,
|
|
37
|
-
createAgentMessageMetadata,
|
|
38
|
-
createServerRunAbortController,
|
|
39
|
-
} from '../runtime/agent-stream-helpers'
|
|
31
|
+
import { createAgentMessageMetadata, createServerRunAbortController } from '../runtime/agent-stream-helpers'
|
|
40
32
|
import { hasApprovalRespondedParts } from '../runtime/approval-continuation'
|
|
41
33
|
import { buildModelInputMessagesWithUploadMetadata, buildReadableUploadMetadataText } from '../runtime/chat-attachments'
|
|
42
34
|
import { hasMessageContent } from '../runtime/chat-message'
|
|
@@ -47,7 +39,6 @@ import { createExecutionPlanInstructionSectionCache } from '../runtime/execution
|
|
|
47
39
|
import { mergeInstructionSections } from '../runtime/instruction-sections'
|
|
48
40
|
import { runPostTurnSideEffects } from '../runtime/post-turn-side-effects'
|
|
49
41
|
import { getRuntimeAdapters, getToolProviders, getTurnHooks } from '../runtime/runtime-extensions'
|
|
50
|
-
import { runSpecialistSession } from '../runtime/specialist-runner'
|
|
51
42
|
import { finalizeTurnRun } from '../runtime/turn-lifecycle'
|
|
52
43
|
import {
|
|
53
44
|
asRecord,
|
|
@@ -67,7 +58,7 @@ import type { WorkstreamState } from '../runtime/workstream-state'
|
|
|
67
58
|
import { assembleWorkstreamTurnContext } from '../runtime/workstream-turn-context'
|
|
68
59
|
import { chatRunRegistry } from '../services/chat-run-registry.service'
|
|
69
60
|
import type { NormalizedWorkstream, WorkstreamRecord } from '../services/workstream.types'
|
|
70
|
-
import {
|
|
61
|
+
import { triageWorkstreamMessage, checkForNextAgent } from '../system-agents/workstream-manager.agent'
|
|
71
62
|
import { safeEnqueue } from '../utils/async'
|
|
72
63
|
import { AppError } from '../utils/errors'
|
|
73
64
|
import { attachmentService } from './attachment.service'
|
|
@@ -114,30 +105,6 @@ async function waitForWorkstreamCompactionIfNeeded(workstreamId: RecordIdRef): P
|
|
|
114
105
|
})
|
|
115
106
|
}
|
|
116
107
|
|
|
117
|
-
function parseChatMessageCandidate(value: unknown): ChatMessage | undefined {
|
|
118
|
-
const parsed = baseChatMessageSchema.safeParse(value)
|
|
119
|
-
if (!parsed.success) return undefined
|
|
120
|
-
return parsed.data as ChatMessage
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function getChatMessageFromToolOutput(output: unknown): ChatMessage | undefined {
|
|
124
|
-
const directCandidate = parseChatMessageCandidate(output)
|
|
125
|
-
if (directCandidate) return directCandidate
|
|
126
|
-
|
|
127
|
-
if (Array.isArray(output)) {
|
|
128
|
-
for (let index = output.length - 1; index >= 0; index -= 1) {
|
|
129
|
-
const candidate = getChatMessageFromToolOutput(output[index])
|
|
130
|
-
if (candidate) return candidate
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (output && typeof output === 'object' && 'message' in output) {
|
|
135
|
-
return getChatMessageFromToolOutput((output as { message?: unknown }).message)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return undefined
|
|
139
|
-
}
|
|
140
|
-
|
|
141
108
|
class WorkstreamTurnError extends AppError {
|
|
142
109
|
constructor(
|
|
143
110
|
message: string,
|
|
@@ -277,6 +244,7 @@ interface StreamAgentResponseParams {
|
|
|
277
244
|
stopWhen?: StopCondition<ToolSet> | Array<StopCondition<ToolSet>>
|
|
278
245
|
prepareStep?: PrepareStepFunction<ToolSet>
|
|
279
246
|
abortSignal?: AbortSignal
|
|
247
|
+
suppressFinish?: boolean
|
|
280
248
|
}
|
|
281
249
|
|
|
282
250
|
async function streamAgentResponse(
|
|
@@ -426,6 +394,15 @@ async function streamAgentResponse(
|
|
|
426
394
|
firstChunkLogged = true
|
|
427
395
|
}
|
|
428
396
|
if (streamParams.writer) {
|
|
397
|
+
if (
|
|
398
|
+
streamParams.suppressFinish &&
|
|
399
|
+
typeof value === 'object' &&
|
|
400
|
+
value !== null &&
|
|
401
|
+
'type' in value &&
|
|
402
|
+
(value as { type: string }).type === 'finish'
|
|
403
|
+
) {
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
429
406
|
streamParams.writer.write(value)
|
|
430
407
|
}
|
|
431
408
|
}
|
|
@@ -850,6 +827,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
|
|
|
850
827
|
filterTools?: (tools: ToolSet) => ToolSet
|
|
851
828
|
includeExecutionPlanTools?: boolean
|
|
852
829
|
metadataPatch?: NonNullable<MessageMetadata>
|
|
830
|
+
suppressFinish?: boolean
|
|
853
831
|
}): Promise<ChatMessage> => {
|
|
854
832
|
const visibleTimer = lotaDebugLogger.timer(`visible:${runParams.agentId}`)
|
|
855
833
|
let runMemoryBlock = memoryBlock
|
|
@@ -885,6 +863,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
|
|
|
885
863
|
additionalInstructionSections: runParams.additionalInstructionSections,
|
|
886
864
|
includeExecutionPlanTools,
|
|
887
865
|
writer,
|
|
866
|
+
suppressFinish: runParams.suppressFinish,
|
|
888
867
|
})
|
|
889
868
|
|
|
890
869
|
visibleTimer.step('stream-agent-response')
|
|
@@ -924,107 +903,106 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
|
|
|
924
903
|
metadataPatch: { trigger: 'plan-turn', planRunId: planTurn.runId, planNodeId: planTurn.nodeId },
|
|
925
904
|
})
|
|
926
905
|
} else {
|
|
927
|
-
const consultSpecialistTool = createTool({
|
|
928
|
-
description: 'Consult one specialist teammate for domain-specific guidance before replying to the user.',
|
|
929
|
-
inputSchema: ConsultSpecialistArgsSchema,
|
|
930
|
-
execute: async function* (
|
|
931
|
-
{ agentId, task }: ConsultSpecialistArgs,
|
|
932
|
-
{ abortSignal: toolAbortSignal }: { abortSignal?: AbortSignal },
|
|
933
|
-
) {
|
|
934
|
-
const specialistTaskMessage = buildSpecialistTaskMessage({ agentId, task })
|
|
935
|
-
const specialistAbortSignal = toolAbortSignal ?? runAbort.signal
|
|
936
|
-
const { result: finalMessage, memoryBlock: nextMemoryBlock } = await runSpecialistSession({
|
|
937
|
-
initialMemoryBlock: memoryBlock,
|
|
938
|
-
buildTools: async ({ memoryBlock: currentMemoryBlock, onAppendMemoryBlock }) =>
|
|
939
|
-
(await buildAgentTools(
|
|
940
|
-
buildTurnToolParams({
|
|
941
|
-
agentId,
|
|
942
|
-
mode: 'fixedWorkstreamMode',
|
|
943
|
-
memoryBlock: currentMemoryBlock,
|
|
944
|
-
onAppendMemoryBlock,
|
|
945
|
-
extraMessages: [specialistTaskMessage],
|
|
946
|
-
includeExecutionPlanTools: false,
|
|
947
|
-
}),
|
|
948
|
-
)) as ToolSet,
|
|
949
|
-
run: async ({ tools, memoryBlock: currentMemoryBlock }) =>
|
|
950
|
-
await streamAgentResponse(
|
|
951
|
-
{ ...streamCtx, memoryBlock: currentMemoryBlock },
|
|
952
|
-
{
|
|
953
|
-
agentId,
|
|
954
|
-
mode: 'fixedWorkstreamMode',
|
|
955
|
-
messages: buildRunInputMessages([specialistTaskMessage]),
|
|
956
|
-
tools: { ...tools, ...toolProviders },
|
|
957
|
-
observer: createObserver(agentId),
|
|
958
|
-
additionalInstructionSections: coreInstructionSections,
|
|
959
|
-
includeExecutionPlanTools: false,
|
|
960
|
-
abortSignal: specialistAbortSignal,
|
|
961
|
-
},
|
|
962
|
-
),
|
|
963
|
-
})
|
|
964
|
-
const committedFinalMessage = withMessageCreatedAt(finalMessage, Date.now())
|
|
965
|
-
memoryBlock = nextMemoryBlock
|
|
966
|
-
yield committedFinalMessage
|
|
967
|
-
return committedFinalMessage
|
|
968
|
-
},
|
|
969
|
-
toModelOutput: ({ output }) => {
|
|
970
|
-
const result = getChatMessageFromToolOutput(output)
|
|
971
|
-
const agentName =
|
|
972
|
-
typeof result?.metadata?.agentName === 'string' && result.metadata.agentName.trim().length > 0
|
|
973
|
-
? result.metadata.agentName.trim()
|
|
974
|
-
: 'Specialist'
|
|
975
|
-
const summary = result ? extractMessageText(result).trim() : ''
|
|
976
|
-
return {
|
|
977
|
-
type: 'text',
|
|
978
|
-
value: summary ? `${agentName}: ${summary}` : `${agentName} completed the requested task.`,
|
|
979
|
-
}
|
|
980
|
-
},
|
|
981
|
-
})
|
|
982
|
-
|
|
983
|
-
const teamThinkTool =
|
|
984
|
-
workstream.mode === 'group'
|
|
985
|
-
? createTeamThinkTool({
|
|
986
|
-
historyMessages: currentMessages,
|
|
987
|
-
latestUserMessageId: referenceUserMessageId,
|
|
988
|
-
orgId: orgRef,
|
|
989
|
-
userId: userRef,
|
|
990
|
-
workstreamId: workstreamRef,
|
|
991
|
-
githubInstalled,
|
|
992
|
-
availableUploads: listReadableUploads(),
|
|
993
|
-
provideRepoTool: indexedRepoContext.provideRepoTool,
|
|
994
|
-
defaultRepoSectionsByAgent: indexedRepoContext.defaultSectionsByAgent as never,
|
|
995
|
-
systemWorkspaceDetails: promptContext.systemWorkspaceDetails,
|
|
996
|
-
getPreSeededMemoriesSection,
|
|
997
|
-
retrievedKnowledgeSection,
|
|
998
|
-
additionalInstructionSections: mergeInstructionSections(
|
|
999
|
-
coreInstructionSections,
|
|
1000
|
-
hookInstructionSections,
|
|
1001
|
-
),
|
|
1002
|
-
getAdditionalInstructionSections: getExecutionPlanInstructionSections,
|
|
1003
|
-
context: buildContextResult,
|
|
1004
|
-
toolProviders,
|
|
1005
|
-
abortSignal: runAbort.signal,
|
|
1006
|
-
})
|
|
1007
|
-
: null
|
|
1008
|
-
|
|
1009
906
|
if (workstream.mode === 'direct') {
|
|
1010
907
|
if (!workstream.agentId) {
|
|
1011
908
|
throw new WorkstreamTurnError('Direct workstreams require an assigned agent.', 400)
|
|
1012
909
|
}
|
|
1013
910
|
await runVisibleAgent({ agentId: workstream.agentId, mode: 'direct' })
|
|
1014
911
|
} else {
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
),
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
912
|
+
// Multi-agent orchestration for group workstreams
|
|
913
|
+
const wsMembers = (workstream as { members?: string[] }).members ?? []
|
|
914
|
+
const members = wsMembers.length > 0 ? wsMembers : [visibleWorkstreamAgentId ?? defaultLeadAgentId]
|
|
915
|
+
const fallbackAgentId = coreWorkstreamProfile?.config.agentId ?? defaultLeadAgentId
|
|
916
|
+
|
|
917
|
+
const recentContext = currentMessages
|
|
918
|
+
.slice(-6)
|
|
919
|
+
.map((m) => `${m.role}: ${extractMessageText(m).slice(0, 200)}`)
|
|
920
|
+
.join('\n')
|
|
921
|
+
|
|
922
|
+
const triageResult = await triageWorkstreamMessage({
|
|
923
|
+
workstreamTitle: workstream.title,
|
|
924
|
+
members,
|
|
925
|
+
messageText,
|
|
926
|
+
recentContext,
|
|
1027
927
|
})
|
|
928
|
+
|
|
929
|
+
const runGroupAgent = async (
|
|
930
|
+
agentId: string,
|
|
931
|
+
options?: { routingContext?: string; suppressFinish?: boolean },
|
|
932
|
+
) => {
|
|
933
|
+
const additionalSections = [...(coreInstructionSections ?? []), ...hookInstructionSections]
|
|
934
|
+
if (options?.routingContext) {
|
|
935
|
+
additionalSections.push(`<routing-context>\n${options.routingContext}\n</routing-context>`)
|
|
936
|
+
}
|
|
937
|
+
// Multi-agent member protocol: be direct, focus on domain
|
|
938
|
+
additionalSections.push(
|
|
939
|
+
'<multi-agent-protocol>\nYou are responding as part of a multi-agent workstream. Focus on your domain expertise. Be direct and concise — another agent may follow up on different aspects.\n</multi-agent-protocol>',
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
return await runVisibleAgent({
|
|
943
|
+
agentId,
|
|
944
|
+
mode: 'workstreamMode',
|
|
945
|
+
skills: coreWorkstreamProfile?.skills ? [...coreWorkstreamProfile.skills] : undefined,
|
|
946
|
+
additionalInstructionSections: additionalSections,
|
|
947
|
+
suppressFinish: options?.suppressFinish,
|
|
948
|
+
})
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (!triageResult) {
|
|
952
|
+
// No specialist match — fallback to owner (core) or chief (non-core), single agent turn
|
|
953
|
+
await runGroupAgent(fallbackAgentId)
|
|
954
|
+
} else {
|
|
955
|
+
// Run first routed agent — suppress finish in case more agents follow
|
|
956
|
+
const respondedAgents: string[] = []
|
|
957
|
+
let lastResponse = await runGroupAgent(triageResult.agentId, {
|
|
958
|
+
routingContext: triageResult.routingContext,
|
|
959
|
+
suppressFinish: true,
|
|
960
|
+
})
|
|
961
|
+
respondedAgents.push(triageResult.agentId)
|
|
962
|
+
|
|
963
|
+
// Check if more agents should respond (max 3 total)
|
|
964
|
+
while (respondedAgents.length < 3) {
|
|
965
|
+
const lastResponseText = extractMessageText(lastResponse).slice(0, 500)
|
|
966
|
+
const checkResult = await checkForNextAgent({
|
|
967
|
+
workstreamTitle: workstream.title,
|
|
968
|
+
members,
|
|
969
|
+
messageText,
|
|
970
|
+
respondedAgents,
|
|
971
|
+
lastResponseSummary: lastResponseText,
|
|
972
|
+
})
|
|
973
|
+
|
|
974
|
+
if (checkResult.done || !checkResult.agentId) break
|
|
975
|
+
|
|
976
|
+
// Insert hidden bridge message between agent turns
|
|
977
|
+
const bridgeMessage: ChatMessage = {
|
|
978
|
+
id: Bun.randomUUIDv7(),
|
|
979
|
+
role: 'user',
|
|
980
|
+
parts: [
|
|
981
|
+
{
|
|
982
|
+
type: 'text',
|
|
983
|
+
text: checkResult.routingContext ?? 'Please also provide your perspective on this topic.',
|
|
984
|
+
},
|
|
985
|
+
],
|
|
986
|
+
metadata: { hidden: true, createdAt: Date.now() } as MessageMetadata,
|
|
987
|
+
}
|
|
988
|
+
await workstreamMessageService.upsertMessages({
|
|
989
|
+
workstreamId: workstreamRef,
|
|
990
|
+
messages: [bridgeMessage],
|
|
991
|
+
})
|
|
992
|
+
currentMessages = upsertChatHistoryMessage(currentMessages, bridgeMessage)
|
|
993
|
+
|
|
994
|
+
lastResponse = await runGroupAgent(checkResult.agentId, {
|
|
995
|
+
routingContext: checkResult.routingContext,
|
|
996
|
+
suppressFinish: true,
|
|
997
|
+
})
|
|
998
|
+
respondedAgents.push(checkResult.agentId)
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Write final finish chunk so the client knows the turn is complete
|
|
1002
|
+
if (writer) {
|
|
1003
|
+
writer.write({ type: 'finish', finishReason: 'stop' } as ChatStreamChunk)
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1028
1006
|
}
|
|
1029
1007
|
}
|
|
1030
1008
|
} finally {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WORKSTREAM, sdkWorkstreamStatusSchema } from '@lota-sdk/shared'
|
|
2
2
|
import { BoundQuery, RecordId, StringRecordId, surql } from 'surrealdb'
|
|
3
3
|
|
|
4
|
-
import { agentDisplayNames, getCoreWorkstreamProfile, isAgentName } from '../config/agent-defaults'
|
|
4
|
+
import { agentDisplayNames, agentRoster, getCoreWorkstreamProfile, isAgentName } from '../config/agent-defaults'
|
|
5
5
|
import { serverLogger } from '../config/logger'
|
|
6
6
|
import { getWorkstreamBootstrapConfig } from '../config/workstream-defaults'
|
|
7
7
|
import { BaseService } from '../db/base.service'
|
|
@@ -152,7 +152,14 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
|
|
|
152
152
|
async createWorkstream(
|
|
153
153
|
userId: RecordIdRef,
|
|
154
154
|
orgId: RecordIdRef,
|
|
155
|
-
options?: {
|
|
155
|
+
options?: {
|
|
156
|
+
title?: string
|
|
157
|
+
mode?: string
|
|
158
|
+
agentId?: string
|
|
159
|
+
core?: boolean
|
|
160
|
+
coreType?: string
|
|
161
|
+
members?: string[]
|
|
162
|
+
},
|
|
156
163
|
): Promise<NormalizedWorkstream> {
|
|
157
164
|
const mode = options?.mode ?? 'group'
|
|
158
165
|
const directAgentId = options?.agentId
|
|
@@ -199,6 +206,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
|
|
|
199
206
|
mode,
|
|
200
207
|
core: false,
|
|
201
208
|
agentId,
|
|
209
|
+
members: [agentId],
|
|
202
210
|
title,
|
|
203
211
|
status: 'regular',
|
|
204
212
|
nameGenerated: true,
|
|
@@ -217,6 +225,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
|
|
|
217
225
|
core: true,
|
|
218
226
|
coreType: resolvedCoreType,
|
|
219
227
|
agentId: coreProfile.config.agentId,
|
|
228
|
+
members: [...coreProfile.members],
|
|
220
229
|
title,
|
|
221
230
|
status: 'regular',
|
|
222
231
|
nameGenerated: true,
|
|
@@ -229,6 +238,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
|
|
|
229
238
|
organizationId: orgId,
|
|
230
239
|
mode,
|
|
231
240
|
core: false,
|
|
241
|
+
members: options?.members ?? [...agentRoster],
|
|
232
242
|
title,
|
|
233
243
|
status: 'regular',
|
|
234
244
|
nameGenerated: options?.title !== undefined && options.title !== WORKSTREAM.DEFAULT_TITLE,
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { ToolLoopAgent } from 'ai'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
import { aiGatewayChatModel } from '../ai-gateway/ai-gateway'
|
|
5
|
+
import { buildAiGatewayDirectCacheHeaders } from '../ai-gateway/cache-headers'
|
|
6
|
+
import { agentDescriptions, agentDisplayNames, managerModelId } from '../config/agent-defaults'
|
|
7
|
+
import {
|
|
8
|
+
OPENROUTER_FAST_REASONING_MODEL_ID,
|
|
9
|
+
OPENROUTER_XHIGH_REASONING_PROVIDER_OPTIONS,
|
|
10
|
+
OPENROUTER_HIGH_REASONING_PROVIDER_OPTIONS,
|
|
11
|
+
} from '../config/model-constants'
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Schemas
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const TriageResultSchema = z.object({ agentId: z.string(), routingContext: z.string() })
|
|
18
|
+
|
|
19
|
+
const CheckResultSchema = z.object({
|
|
20
|
+
done: z.boolean(),
|
|
21
|
+
agentId: z.string().optional(),
|
|
22
|
+
routingContext: z.string().optional(),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export type ManagerTriageResult = z.infer<typeof TriageResultSchema>
|
|
26
|
+
export type ManagerCheckResult = z.infer<typeof CheckResultSchema>
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Helpers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
function buildMembersDescription(members: readonly string[]): string {
|
|
33
|
+
return members
|
|
34
|
+
.map((id) => {
|
|
35
|
+
const display = agentDisplayNames[id] ?? id
|
|
36
|
+
const desc = agentDescriptions[id] ?? ''
|
|
37
|
+
return `- ${display} (id: ${id}): ${desc}`
|
|
38
|
+
})
|
|
39
|
+
.join('\n')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractJson(text: string): unknown {
|
|
43
|
+
const match = text.match(/\{[\s\S]*\}/)
|
|
44
|
+
if (!match) return null
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(match[0])
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Prompts
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
const TRIAGE_SYSTEM_PROMPT = `You are a workstream message router. Decide which team member should respond to the user message.
|
|
57
|
+
|
|
58
|
+
Rules:
|
|
59
|
+
- Pick the single best-fit agent from the members list based on domain expertise.
|
|
60
|
+
- If no specialist clearly matches (general chat, greetings, coordination), respond with agentId "".
|
|
61
|
+
- Be decisive. Reply with ONLY a JSON object, no other text.
|
|
62
|
+
|
|
63
|
+
Format: {"agentId":"<id>","routingContext":"<1-sentence instruction>"}`
|
|
64
|
+
|
|
65
|
+
const CHECK_SYSTEM_PROMPT = `You decide if another team member should also respond after the previous agent.
|
|
66
|
+
|
|
67
|
+
Rules:
|
|
68
|
+
- Only add another agent if the user's question has a clearly separate dimension not yet covered.
|
|
69
|
+
- Do NOT add agents for agreement or acknowledgement.
|
|
70
|
+
- Most messages need only one agent. Reply with ONLY a JSON object, no other text.
|
|
71
|
+
|
|
72
|
+
Format: {"done":true} or {"done":false,"agentId":"<id>","routingContext":"<1-sentence>"}`
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Agent functions
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
function createManagerAgent(systemPrompt: string) {
|
|
79
|
+
const modelId = managerModelId ?? OPENROUTER_FAST_REASONING_MODEL_ID
|
|
80
|
+
const providerOptions = managerModelId
|
|
81
|
+
? OPENROUTER_XHIGH_REASONING_PROVIDER_OPTIONS
|
|
82
|
+
: OPENROUTER_HIGH_REASONING_PROVIDER_OPTIONS
|
|
83
|
+
return new ToolLoopAgent({
|
|
84
|
+
id: 'workstream-manager',
|
|
85
|
+
model: aiGatewayChatModel(modelId),
|
|
86
|
+
headers: buildAiGatewayDirectCacheHeaders('workstream-manager'),
|
|
87
|
+
providerOptions,
|
|
88
|
+
instructions: systemPrompt,
|
|
89
|
+
maxOutputTokens: 256,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function triageWorkstreamMessage(params: {
|
|
94
|
+
workstreamTitle: string
|
|
95
|
+
members: readonly string[]
|
|
96
|
+
messageText: string
|
|
97
|
+
recentContext?: string
|
|
98
|
+
}): Promise<ManagerTriageResult | null> {
|
|
99
|
+
const membersDesc = buildMembersDescription(params.members)
|
|
100
|
+
const prompt = [
|
|
101
|
+
`Workstream: "${params.workstreamTitle}"`,
|
|
102
|
+
`Members:\n${membersDesc}`,
|
|
103
|
+
params.recentContext ? `Recent context:\n${params.recentContext}` : '',
|
|
104
|
+
`User message: "${params.messageText}"`,
|
|
105
|
+
]
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.join('\n\n')
|
|
108
|
+
|
|
109
|
+
const agent = createManagerAgent(TRIAGE_SYSTEM_PROMPT)
|
|
110
|
+
const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 30_000 } })
|
|
111
|
+
|
|
112
|
+
const json = extractJson(typeof result.text === 'string' ? result.text : '')
|
|
113
|
+
const parsed = TriageResultSchema.safeParse(json)
|
|
114
|
+
if (!parsed.success) return null
|
|
115
|
+
if (!parsed.data.agentId) return null
|
|
116
|
+
if (!params.members.includes(parsed.data.agentId)) return null
|
|
117
|
+
|
|
118
|
+
return parsed.data
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function checkForNextAgent(params: {
|
|
122
|
+
workstreamTitle: string
|
|
123
|
+
members: readonly string[]
|
|
124
|
+
messageText: string
|
|
125
|
+
respondedAgents: string[]
|
|
126
|
+
lastResponseSummary: string
|
|
127
|
+
}): Promise<ManagerCheckResult> {
|
|
128
|
+
const remainingMembers = params.members.filter((id) => !params.respondedAgents.includes(id))
|
|
129
|
+
if (remainingMembers.length === 0) return { done: true }
|
|
130
|
+
|
|
131
|
+
const membersDesc = buildMembersDescription(remainingMembers)
|
|
132
|
+
const respondedList = params.respondedAgents.map((id) => agentDisplayNames[id] ?? id).join(', ')
|
|
133
|
+
|
|
134
|
+
const prompt = [
|
|
135
|
+
`Workstream: "${params.workstreamTitle}"`,
|
|
136
|
+
`Remaining members:\n${membersDesc}`,
|
|
137
|
+
`Already responded: ${respondedList}`,
|
|
138
|
+
`User message: "${params.messageText}"`,
|
|
139
|
+
`Last response summary: "${params.lastResponseSummary}"`,
|
|
140
|
+
].join('\n\n')
|
|
141
|
+
|
|
142
|
+
const agent = createManagerAgent(CHECK_SYSTEM_PROMPT)
|
|
143
|
+
const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 30_000 } })
|
|
144
|
+
|
|
145
|
+
const json = extractJson(typeof result.text === 'string' ? result.text : '')
|
|
146
|
+
const parsed = CheckResultSchema.safeParse(json)
|
|
147
|
+
if (!parsed.success) return { done: true }
|
|
148
|
+
if (parsed.data.done) return { done: true }
|
|
149
|
+
if (!parsed.data.agentId || !remainingMembers.includes(parsed.data.agentId)) return { done: true }
|
|
150
|
+
|
|
151
|
+
return parsed.data
|
|
152
|
+
}
|