@lota-sdk/core 0.1.36 → 0.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/config/agent-defaults.ts +3 -0
- package/src/config/model-constants.ts +0 -1
- package/src/create-runtime.ts +1 -0
- package/src/runtime/runtime-config.ts +1 -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 +38 -11
- package/src/system-agents/index.ts +1 -1
- package/src/system-agents/workstream-router.agent.ts +157 -0
- package/src/system-agents/workstream-manager.agent.ts +0 -153
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lota-sdk/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.38",
|
|
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.38",
|
|
36
36
|
"@mendable/firecrawl-js": "^4.18.0",
|
|
37
37
|
"@surrealdb/node": "^3.0.3",
|
|
38
38
|
"ai": "^6.0.141",
|
|
@@ -12,6 +12,7 @@ function defaultGetAgentRuntimeConfig(): Record<string, never> {
|
|
|
12
12
|
export let agentDisplayNames: Record<string, string> = {}
|
|
13
13
|
export let agentShortDisplayNames: Record<string, string> = {}
|
|
14
14
|
export let agentDescriptions: Record<string, string> = {}
|
|
15
|
+
export let routerModelId: string | undefined = undefined
|
|
15
16
|
export let agentRoster: readonly string[] = []
|
|
16
17
|
export let leadAgentId = ''
|
|
17
18
|
export let teamConsultParticipants: readonly string[] = []
|
|
@@ -38,6 +39,7 @@ export function configureAgents(config: {
|
|
|
38
39
|
displayNames: Record<string, string>
|
|
39
40
|
shortDisplayNames?: Record<string, string>
|
|
40
41
|
descriptions?: Record<string, string>
|
|
42
|
+
routerModelId?: string
|
|
41
43
|
teamConsultParticipants: readonly string[]
|
|
42
44
|
getCoreWorkstreamProfile?: (coreType: string) => CoreWorkstreamProfile
|
|
43
45
|
}): void {
|
|
@@ -50,6 +52,7 @@ export function configureAgents(config: {
|
|
|
50
52
|
agentDisplayNames = config.displayNames
|
|
51
53
|
agentShortDisplayNames = config.shortDisplayNames ?? {}
|
|
52
54
|
agentDescriptions = config.descriptions ?? {}
|
|
55
|
+
routerModelId = config.routerModelId
|
|
53
56
|
teamConsultParticipants = config.teamConsultParticipants
|
|
54
57
|
if (config.getCoreWorkstreamProfile) {
|
|
55
58
|
getCoreWorkstreamProfile = config.getCoreWorkstreamProfile
|
|
@@ -12,7 +12,6 @@ export {
|
|
|
12
12
|
OPENROUTER_TEAM_AGENT_MODEL_ID,
|
|
13
13
|
OPENROUTER_WEB_RESEARCH_MODEL_ID,
|
|
14
14
|
OPENROUTER_XHIGH_REASONING_PROVIDER_OPTIONS,
|
|
15
|
-
OPENROUTER_MANAGER_MODEL_ID,
|
|
16
15
|
} from '@lota-sdk/shared'
|
|
17
16
|
|
|
18
17
|
// Both aliases point to the same underlying model. Keep the names separate so
|
package/src/create-runtime.ts
CHANGED
|
@@ -251,6 +251,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
|
|
|
251
251
|
displayNames: agentDisplayNames,
|
|
252
252
|
shortDisplayNames: runtimeConfig.agents.shortDisplayNames,
|
|
253
253
|
descriptions: runtimeConfig.agents.descriptions,
|
|
254
|
+
routerModelId: runtimeConfig.agents.routerModelId,
|
|
254
255
|
teamConsultParticipants: runtimeConfig.agents.teamConsultParticipants,
|
|
255
256
|
getCoreWorkstreamProfile: runtimeConfig.agents.getCoreWorkstreamProfile,
|
|
256
257
|
})
|
|
@@ -176,6 +176,7 @@ const agentsConfigSchema = z
|
|
|
176
176
|
descriptions: z
|
|
177
177
|
.custom<Record<string, string>>(isStringRecord, { error: 'agents.descriptions must be a string record' })
|
|
178
178
|
.optional(),
|
|
179
|
+
routerModelId: z.string().trim().min(1).optional(),
|
|
179
180
|
teamConsultParticipants: z.array(z.string().trim().min(1)),
|
|
180
181
|
getCoreWorkstreamProfile: z
|
|
181
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
|
|
@@ -58,7 +58,7 @@ import type { WorkstreamState } from '../runtime/workstream-state'
|
|
|
58
58
|
import { assembleWorkstreamTurnContext } from '../runtime/workstream-turn-context'
|
|
59
59
|
import { chatRunRegistry } from '../services/chat-run-registry.service'
|
|
60
60
|
import type { NormalizedWorkstream, WorkstreamRecord } from '../services/workstream.types'
|
|
61
|
-
import { triageWorkstreamMessage, checkForNextAgent } from '../system-agents/workstream-
|
|
61
|
+
import { triageWorkstreamMessage, checkForNextAgent } from '../system-agents/workstream-router.agent'
|
|
62
62
|
import { safeEnqueue } from '../utils/async'
|
|
63
63
|
import { AppError } from '../utils/errors'
|
|
64
64
|
import { attachmentService } from './attachment.service'
|
|
@@ -244,6 +244,7 @@ interface StreamAgentResponseParams {
|
|
|
244
244
|
stopWhen?: StopCondition<ToolSet> | Array<StopCondition<ToolSet>>
|
|
245
245
|
prepareStep?: PrepareStepFunction<ToolSet>
|
|
246
246
|
abortSignal?: AbortSignal
|
|
247
|
+
suppressFinish?: boolean
|
|
247
248
|
}
|
|
248
249
|
|
|
249
250
|
async function streamAgentResponse(
|
|
@@ -393,6 +394,15 @@ async function streamAgentResponse(
|
|
|
393
394
|
firstChunkLogged = true
|
|
394
395
|
}
|
|
395
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
|
+
}
|
|
396
406
|
streamParams.writer.write(value)
|
|
397
407
|
}
|
|
398
408
|
}
|
|
@@ -817,6 +827,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
|
|
|
817
827
|
filterTools?: (tools: ToolSet) => ToolSet
|
|
818
828
|
includeExecutionPlanTools?: boolean
|
|
819
829
|
metadataPatch?: NonNullable<MessageMetadata>
|
|
830
|
+
suppressFinish?: boolean
|
|
820
831
|
}): Promise<ChatMessage> => {
|
|
821
832
|
const visibleTimer = lotaDebugLogger.timer(`visible:${runParams.agentId}`)
|
|
822
833
|
let runMemoryBlock = memoryBlock
|
|
@@ -852,6 +863,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
|
|
|
852
863
|
additionalInstructionSections: runParams.additionalInstructionSections,
|
|
853
864
|
includeExecutionPlanTools,
|
|
854
865
|
writer,
|
|
866
|
+
suppressFinish: runParams.suppressFinish,
|
|
855
867
|
})
|
|
856
868
|
|
|
857
869
|
visibleTimer.step('stream-agent-response')
|
|
@@ -898,8 +910,8 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
|
|
|
898
910
|
await runVisibleAgent({ agentId: workstream.agentId, mode: 'direct' })
|
|
899
911
|
} else {
|
|
900
912
|
// Multi-agent orchestration for group workstreams
|
|
901
|
-
const members
|
|
902
|
-
|
|
913
|
+
const wsMembers = (workstream as { members?: string[] }).members ?? []
|
|
914
|
+
const members = wsMembers.length > 0 ? wsMembers : [visibleWorkstreamAgentId ?? defaultLeadAgentId]
|
|
903
915
|
const fallbackAgentId = coreWorkstreamProfile?.config.agentId ?? defaultLeadAgentId
|
|
904
916
|
|
|
905
917
|
const recentContext = currentMessages
|
|
@@ -914,10 +926,13 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
|
|
|
914
926
|
recentContext,
|
|
915
927
|
})
|
|
916
928
|
|
|
917
|
-
const runGroupAgent = async (
|
|
929
|
+
const runGroupAgent = async (
|
|
930
|
+
agentId: string,
|
|
931
|
+
options?: { routingContext?: string; suppressFinish?: boolean },
|
|
932
|
+
) => {
|
|
918
933
|
const additionalSections = [...(coreInstructionSections ?? []), ...hookInstructionSections]
|
|
919
|
-
if (routingContext) {
|
|
920
|
-
additionalSections.push(`<routing-context>\n${routingContext}\n</routing-context>`)
|
|
934
|
+
if (options?.routingContext) {
|
|
935
|
+
additionalSections.push(`<routing-context>\n${options.routingContext}\n</routing-context>`)
|
|
921
936
|
}
|
|
922
937
|
// Multi-agent member protocol: be direct, focus on domain
|
|
923
938
|
additionalSections.push(
|
|
@@ -929,16 +944,20 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
|
|
|
929
944
|
mode: 'workstreamMode',
|
|
930
945
|
skills: coreWorkstreamProfile?.skills ? [...coreWorkstreamProfile.skills] : undefined,
|
|
931
946
|
additionalInstructionSections: additionalSections,
|
|
947
|
+
suppressFinish: options?.suppressFinish,
|
|
932
948
|
})
|
|
933
949
|
}
|
|
934
950
|
|
|
935
951
|
if (!triageResult) {
|
|
936
|
-
// No specialist match — fallback to owner (core) or chief (non-core)
|
|
952
|
+
// No specialist match — fallback to owner (core) or chief (non-core), single agent turn
|
|
937
953
|
await runGroupAgent(fallbackAgentId)
|
|
938
954
|
} else {
|
|
939
|
-
// Run first routed agent
|
|
955
|
+
// Run first routed agent — suppress finish in case more agents follow
|
|
940
956
|
const respondedAgents: string[] = []
|
|
941
|
-
let lastResponse = await runGroupAgent(triageResult.agentId,
|
|
957
|
+
let lastResponse = await runGroupAgent(triageResult.agentId, {
|
|
958
|
+
routingContext: triageResult.routingContext,
|
|
959
|
+
suppressFinish: true,
|
|
960
|
+
})
|
|
942
961
|
respondedAgents.push(triageResult.agentId)
|
|
943
962
|
|
|
944
963
|
// Check if more agents should respond (max 3 total)
|
|
@@ -964,7 +983,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
|
|
|
964
983
|
text: checkResult.routingContext ?? 'Please also provide your perspective on this topic.',
|
|
965
984
|
},
|
|
966
985
|
],
|
|
967
|
-
metadata: { hidden: true, createdAt: Date.now() },
|
|
986
|
+
metadata: { hidden: true, createdAt: Date.now() } as MessageMetadata,
|
|
968
987
|
}
|
|
969
988
|
await workstreamMessageService.upsertMessages({
|
|
970
989
|
workstreamId: workstreamRef,
|
|
@@ -972,9 +991,17 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
|
|
|
972
991
|
})
|
|
973
992
|
currentMessages = upsertChatHistoryMessage(currentMessages, bridgeMessage)
|
|
974
993
|
|
|
975
|
-
lastResponse = await runGroupAgent(checkResult.agentId,
|
|
994
|
+
lastResponse = await runGroupAgent(checkResult.agentId, {
|
|
995
|
+
routingContext: checkResult.routingContext,
|
|
996
|
+
suppressFinish: true,
|
|
997
|
+
})
|
|
976
998
|
respondedAgents.push(checkResult.agentId)
|
|
977
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
|
+
}
|
|
978
1005
|
}
|
|
979
1006
|
}
|
|
980
1007
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
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, routerModelId } 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 RouterTriageResult = z.infer<typeof TriageResultSchema>
|
|
26
|
+
export type RouterCheckResult = 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 createRouterAgent(systemPrompt: string) {
|
|
79
|
+
const modelId = routerModelId ?? OPENROUTER_FAST_REASONING_MODEL_ID
|
|
80
|
+
const providerOptions = routerModelId
|
|
81
|
+
? {
|
|
82
|
+
openai: {
|
|
83
|
+
...OPENROUTER_XHIGH_REASONING_PROVIDER_OPTIONS.openai,
|
|
84
|
+
provider: { order: ['groq'], allow_fallbacks: true },
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
: OPENROUTER_HIGH_REASONING_PROVIDER_OPTIONS
|
|
88
|
+
return new ToolLoopAgent({
|
|
89
|
+
id: 'workstream-router',
|
|
90
|
+
model: aiGatewayChatModel(modelId),
|
|
91
|
+
headers: buildAiGatewayDirectCacheHeaders('workstream-router'),
|
|
92
|
+
providerOptions,
|
|
93
|
+
instructions: systemPrompt,
|
|
94
|
+
maxOutputTokens: 256,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function triageWorkstreamMessage(params: {
|
|
99
|
+
workstreamTitle: string
|
|
100
|
+
members: readonly string[]
|
|
101
|
+
messageText: string
|
|
102
|
+
recentContext?: string
|
|
103
|
+
}): Promise<RouterTriageResult | null> {
|
|
104
|
+
const membersDesc = buildMembersDescription(params.members)
|
|
105
|
+
const prompt = [
|
|
106
|
+
`Workstream: "${params.workstreamTitle}"`,
|
|
107
|
+
`Members:\n${membersDesc}`,
|
|
108
|
+
params.recentContext ? `Recent context:\n${params.recentContext}` : '',
|
|
109
|
+
`User message: "${params.messageText}"`,
|
|
110
|
+
]
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.join('\n\n')
|
|
113
|
+
|
|
114
|
+
const agent = createRouterAgent(TRIAGE_SYSTEM_PROMPT)
|
|
115
|
+
const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 30_000 } })
|
|
116
|
+
|
|
117
|
+
const json = extractJson(typeof result.text === 'string' ? result.text : '')
|
|
118
|
+
const parsed = TriageResultSchema.safeParse(json)
|
|
119
|
+
if (!parsed.success) return null
|
|
120
|
+
if (!parsed.data.agentId) return null
|
|
121
|
+
if (!params.members.includes(parsed.data.agentId)) return null
|
|
122
|
+
|
|
123
|
+
return parsed.data
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function checkForNextAgent(params: {
|
|
127
|
+
workstreamTitle: string
|
|
128
|
+
members: readonly string[]
|
|
129
|
+
messageText: string
|
|
130
|
+
respondedAgents: string[]
|
|
131
|
+
lastResponseSummary: string
|
|
132
|
+
}): Promise<RouterCheckResult> {
|
|
133
|
+
const remainingMembers = params.members.filter((id) => !params.respondedAgents.includes(id))
|
|
134
|
+
if (remainingMembers.length === 0) return { done: true }
|
|
135
|
+
|
|
136
|
+
const membersDesc = buildMembersDescription(remainingMembers)
|
|
137
|
+
const respondedList = params.respondedAgents.map((id) => agentDisplayNames[id] ?? id).join(', ')
|
|
138
|
+
|
|
139
|
+
const prompt = [
|
|
140
|
+
`Workstream: "${params.workstreamTitle}"`,
|
|
141
|
+
`Remaining members:\n${membersDesc}`,
|
|
142
|
+
`Already responded: ${respondedList}`,
|
|
143
|
+
`User message: "${params.messageText}"`,
|
|
144
|
+
`Last response summary: "${params.lastResponseSummary}"`,
|
|
145
|
+
].join('\n\n')
|
|
146
|
+
|
|
147
|
+
const agent = createRouterAgent(CHECK_SYSTEM_PROMPT)
|
|
148
|
+
const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 30_000 } })
|
|
149
|
+
|
|
150
|
+
const json = extractJson(typeof result.text === 'string' ? result.text : '')
|
|
151
|
+
const parsed = CheckResultSchema.safeParse(json)
|
|
152
|
+
if (!parsed.success) return { done: true }
|
|
153
|
+
if (parsed.data.done) return { done: true }
|
|
154
|
+
if (!parsed.data.agentId || !remainingMembers.includes(parsed.data.agentId)) return { done: true }
|
|
155
|
+
|
|
156
|
+
return parsed.data
|
|
157
|
+
}
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import { Output, 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 } from '../config/agent-defaults'
|
|
7
|
-
import { OPENROUTER_MANAGER_MODEL_ID, OPENROUTER_XHIGH_REASONING_PROVIDER_OPTIONS } from '../config/model-constants'
|
|
8
|
-
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Schemas
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
const TriageResultSchema = z.object({
|
|
14
|
-
agentId: z.string().describe('The agent ID that should respond next'),
|
|
15
|
-
routingContext: z.string().describe('Brief instruction for the selected agent about what to focus on'),
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
const CheckResultSchema = z.object({
|
|
19
|
-
done: z.boolean().describe('true if no more agents need to respond'),
|
|
20
|
-
agentId: z.string().optional().describe('Next agent ID if done is false'),
|
|
21
|
-
routingContext: z.string().optional().describe('Brief instruction for the next agent if done is false'),
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
export type ManagerTriageResult = z.infer<typeof TriageResultSchema>
|
|
25
|
-
export type ManagerCheckResult = z.infer<typeof CheckResultSchema>
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// Prompt builders
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
function buildMembersDescription(members: readonly string[]): string {
|
|
32
|
-
return members
|
|
33
|
-
.map((id) => {
|
|
34
|
-
const display = agentDisplayNames[id] ?? id
|
|
35
|
-
const desc = agentDescriptions[id] ?? ''
|
|
36
|
-
return `- **${display}** (id: \`${id}\`): ${desc}`
|
|
37
|
-
})
|
|
38
|
-
.join('\n')
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const TRIAGE_SYSTEM_PROMPT = `You are a workstream message router. Your job is to decide which team member should respond to a user message.
|
|
42
|
-
|
|
43
|
-
<rules>
|
|
44
|
-
- Pick the single best-fit agent from the members list.
|
|
45
|
-
- Consider the agent's domain expertise against the user's question.
|
|
46
|
-
- If the message is clearly about a specific domain (marketing, tech, product, finance, etc.), route to the domain specialist.
|
|
47
|
-
- If multiple domains are equally relevant, pick the most relevant one — a follow-up check will decide if another agent should also respond.
|
|
48
|
-
- If no specialist clearly matches (general chat, greetings, coordination), respond with an empty agentId to signal fallback.
|
|
49
|
-
- Be decisive. Do not overthink.
|
|
50
|
-
</rules>
|
|
51
|
-
|
|
52
|
-
<output-format>
|
|
53
|
-
Respond with JSON only: { "agentId": "<agent-id>", "routingContext": "<1-sentence instruction>" }
|
|
54
|
-
If no specialist matches, respond: { "agentId": "", "routingContext": "" }
|
|
55
|
-
</output-format>`
|
|
56
|
-
|
|
57
|
-
const CHECK_SYSTEM_PROMPT = `You are a workstream conversation reviewer. After an agent has responded to a user message, you decide if another team member should also respond.
|
|
58
|
-
|
|
59
|
-
<rules>
|
|
60
|
-
- Only add another agent if the user's question has a clearly separate dimension that the previous agent(s) did not cover.
|
|
61
|
-
- Do NOT add agents just for acknowledgement or agreement.
|
|
62
|
-
- Do NOT repeat the same domain. If the CTO already covered tech, don't add another tech perspective.
|
|
63
|
-
- Most messages need only one agent. Multi-agent responses should be the exception, not the rule.
|
|
64
|
-
- Maximum 3 total agent responses per user message.
|
|
65
|
-
</rules>
|
|
66
|
-
|
|
67
|
-
<output-format>
|
|
68
|
-
Respond with JSON only: { "done": true } or { "done": false, "agentId": "<agent-id>", "routingContext": "<1-sentence instruction>" }
|
|
69
|
-
</output-format>`
|
|
70
|
-
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
72
|
-
// Agent functions
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
|
|
75
|
-
function createManagerAgent(systemPrompt: string, schema: z.ZodSchema) {
|
|
76
|
-
return new ToolLoopAgent({
|
|
77
|
-
id: 'workstream-manager',
|
|
78
|
-
model: aiGatewayChatModel(OPENROUTER_MANAGER_MODEL_ID),
|
|
79
|
-
headers: buildAiGatewayDirectCacheHeaders('workstream-manager'),
|
|
80
|
-
providerOptions: OPENROUTER_XHIGH_REASONING_PROVIDER_OPTIONS,
|
|
81
|
-
instructions: systemPrompt,
|
|
82
|
-
output: Output.object({ schema }),
|
|
83
|
-
maxOutputTokens: 256,
|
|
84
|
-
})
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export async function triageWorkstreamMessage(params: {
|
|
88
|
-
workstreamTitle: string
|
|
89
|
-
members: readonly string[]
|
|
90
|
-
messageText: string
|
|
91
|
-
recentContext?: string
|
|
92
|
-
}): Promise<ManagerTriageResult | null> {
|
|
93
|
-
const membersDesc = buildMembersDescription(params.members)
|
|
94
|
-
const prompt = [
|
|
95
|
-
`Workstream: "${params.workstreamTitle}"`,
|
|
96
|
-
`\nMembers:\n${membersDesc}`,
|
|
97
|
-
params.recentContext ? `\nRecent context:\n${params.recentContext}` : '',
|
|
98
|
-
`\nUser message: "${params.messageText}"`,
|
|
99
|
-
]
|
|
100
|
-
.filter(Boolean)
|
|
101
|
-
.join('\n')
|
|
102
|
-
|
|
103
|
-
const agent = createManagerAgent(TRIAGE_SYSTEM_PROMPT, TriageResultSchema)
|
|
104
|
-
const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 15_000 } })
|
|
105
|
-
|
|
106
|
-
const parsed = TriageResultSchema.safeParse(result.output)
|
|
107
|
-
if (!parsed.success) return null
|
|
108
|
-
|
|
109
|
-
// Empty agentId = no specialist match, fallback to owner/chief
|
|
110
|
-
if (!parsed.data.agentId) return null
|
|
111
|
-
|
|
112
|
-
// Validate agent is in members list
|
|
113
|
-
if (!params.members.includes(parsed.data.agentId)) return null
|
|
114
|
-
|
|
115
|
-
return parsed.data
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export async function checkForNextAgent(params: {
|
|
119
|
-
workstreamTitle: string
|
|
120
|
-
members: readonly string[]
|
|
121
|
-
messageText: string
|
|
122
|
-
respondedAgents: string[]
|
|
123
|
-
lastResponseSummary: string
|
|
124
|
-
}): Promise<ManagerCheckResult> {
|
|
125
|
-
const remainingMembers = params.members.filter((id) => !params.respondedAgents.includes(id))
|
|
126
|
-
if (remainingMembers.length === 0) return { done: true }
|
|
127
|
-
|
|
128
|
-
const membersDesc = buildMembersDescription(remainingMembers)
|
|
129
|
-
const respondedList = params.respondedAgents.map((id) => agentDisplayNames[id] ?? id).join(', ')
|
|
130
|
-
|
|
131
|
-
const prompt = [
|
|
132
|
-
`Workstream: "${params.workstreamTitle}"`,
|
|
133
|
-
`\nRemaining members:\n${membersDesc}`,
|
|
134
|
-
`\nAlready responded: ${respondedList}`,
|
|
135
|
-
`\nUser message: "${params.messageText}"`,
|
|
136
|
-
`\nLast response summary: "${params.lastResponseSummary}"`,
|
|
137
|
-
].join('\n')
|
|
138
|
-
|
|
139
|
-
const agent = createManagerAgent(CHECK_SYSTEM_PROMPT, CheckResultSchema)
|
|
140
|
-
const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 15_000 } })
|
|
141
|
-
|
|
142
|
-
const parsed = CheckResultSchema.safeParse(result.output)
|
|
143
|
-
if (!parsed.success) return { done: true }
|
|
144
|
-
|
|
145
|
-
if (parsed.data.done) return { done: true }
|
|
146
|
-
|
|
147
|
-
// Validate next agent is in remaining members
|
|
148
|
-
if (!parsed.data.agentId || !remainingMembers.includes(parsed.data.agentId)) {
|
|
149
|
-
return { done: true }
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return parsed.data
|
|
153
|
-
}
|