@lota-sdk/core 0.2.1 → 0.2.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lota-sdk/core",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -28,18 +28,18 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@ai-sdk/devtools": "^0.0.15",
31
- "@ai-sdk/openai": "^3.0.48",
31
+ "@ai-sdk/openai": "^3.0.50",
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.2.1",
36
- "@mendable/firecrawl-js": "^4.18.0",
35
+ "@lota-sdk/shared": "0.2.3",
36
+ "@mendable/firecrawl-js": "^4.18.1",
37
37
  "@surrealdb/node": "^3.0.3",
38
- "ai": "^6.0.141",
39
- "bullmq": "^5.71.1",
38
+ "ai": "^6.0.145",
39
+ "bullmq": "^5.73.0",
40
40
  "chat": "^4.23.0",
41
41
  "cron-parser": "^5.5.0",
42
- "hono": "^4.12.9",
42
+ "hono": "^4.12.10",
43
43
  "ioredis": "5.9.3",
44
44
  "mammoth": "^1.12.0",
45
45
  "pdf-parse": "^2.4.5",
@@ -91,7 +91,7 @@ Do not call additional tools in the same response.
91
91
  Do not ask when reasonable defaults exist or information is retrievable.`,
92
92
  })
93
93
 
94
- const researchSkillTools = ['researchTopic', 'fetchWebpage', 'inspectWebsite'] as const
94
+ const researchSkillTools = ['searchWeb', 'researchTopic', 'fetchWebpage', 'inspectWebsite'] as const
95
95
 
96
96
  export const researchSkill = defineSkill({
97
97
  name: 'research',
@@ -103,8 +103,9 @@ export const researchSkill = defineSkill({
103
103
  Use for external information: market research, competitive analysis, fact verification.
104
104
 
105
105
  ## Tools
106
- - \`researchTopic\` — delegate to research agent. For broad research, call 2-3 in parallel with different focused tasks.
107
- - \`fetchWebpage\` — only when user shares a specific URL. Do not use for general research.
106
+ - \`searchWeb\` — fastest path for narrow current lookups, finding candidate sources, and checking recency-sensitive facts. Prefer this first when a quick answer is enough.
107
+ - \`researchTopic\` — delegate to the research agent only when the task needs deeper multi-source synthesis or a structured research brief.
108
+ - \`fetchWebpage\` — use when the user shares a specific URL or \`searchWeb\` surfaces a page worth reading in full.
108
109
  - \`inspectWebsite\` — structured analysis of a website. Pass URL in \`url\` field. Use \`forceRefresh: true\` to overwrite.
109
110
 
110
111
  ## Output
@@ -0,0 +1,62 @@
1
+ import { agentDisplayNames } from '../config/agent-defaults'
2
+ import { asRecord, readOptionalString } from './workstream-chat-helpers'
3
+
4
+ interface RuntimeAgentIdentityOverrides {
5
+ displayNamesById: Partial<Record<string, string>>
6
+ shortDisplayNamesById: Partial<Record<string, string>>
7
+ primaryLabelsById: Partial<Record<string, string>>
8
+ secondaryLabelsById: Partial<Record<string, string>>
9
+ templateRoleIdsByAgentId: Partial<Record<string, string>>
10
+ routingAliasesByAgentId: Partial<Record<string, string[]>>
11
+ }
12
+
13
+ function readStringRecord(value: unknown): Partial<Record<string, string>> {
14
+ const record = asRecord(value)
15
+ if (!record) return {}
16
+
17
+ return Object.fromEntries(
18
+ Object.entries(record)
19
+ .map(([key, entry]) => [key, readOptionalString(entry)] as const)
20
+ .filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
21
+ )
22
+ }
23
+
24
+ function readStringArrayRecord(value: unknown): Partial<Record<string, string[]>> {
25
+ const record = asRecord(value)
26
+ if (!record) return {}
27
+
28
+ return Object.fromEntries(
29
+ Object.entries(record).map(([key, entry]) => [
30
+ key,
31
+ Array.isArray(entry)
32
+ ? entry.map((item) => readOptionalString(item)).filter((item): item is string => typeof item === 'string')
33
+ : [],
34
+ ]),
35
+ )
36
+ }
37
+
38
+ function readStringOverride(record: Partial<Record<string, string>>, key: string): string | undefined {
39
+ return Object.hasOwn(record, key) ? record[key] : undefined
40
+ }
41
+
42
+ export function readRuntimeAgentIdentityOverrides(
43
+ context: Record<string, unknown> | null | undefined,
44
+ ): RuntimeAgentIdentityOverrides {
45
+ return {
46
+ displayNamesById: readStringRecord(context?.agentDisplayNamesById),
47
+ shortDisplayNamesById: readStringRecord(context?.agentShortDisplayNamesById),
48
+ primaryLabelsById: readStringRecord(context?.agentPrimaryLabelsById),
49
+ secondaryLabelsById: readStringRecord(context?.agentSecondaryLabelsById),
50
+ templateRoleIdsByAgentId: readStringRecord(context?.agentTemplateRoleIdsById),
51
+ routingAliasesByAgentId: readStringArrayRecord(context?.agentRoutingAliasesById),
52
+ }
53
+ }
54
+
55
+ export function resolveRuntimeAgentDisplayName(overrides: RuntimeAgentIdentityOverrides, agentId: string): string {
56
+ const override = readStringOverride(overrides.displayNamesById, agentId)
57
+ if (override !== undefined) {
58
+ return override
59
+ }
60
+
61
+ return agentDisplayNames[agentId] ?? agentId
62
+ }
@@ -29,6 +29,17 @@ import type { NormalizedWorkstream, WorkstreamRecord } from '../services/workstr
29
29
  import { safeEnqueue } from '../utils/async'
30
30
  import { toIsoDateTimeString } from '../utils/date-time'
31
31
 
32
+ function resolveDisplayName(agentId: string, overrides?: Partial<Record<string, string>>): string {
33
+ if (overrides && Object.hasOwn(overrides, agentId)) {
34
+ const override = overrides[agentId]
35
+ if (override !== undefined) {
36
+ return override
37
+ }
38
+ }
39
+
40
+ return agentDisplayNames[agentId] ?? agentId
41
+ }
42
+
32
43
  function buildRecentActivityChatDeepLink(params: {
33
44
  workstream: NormalizedWorkstream
34
45
  workstreamId: string
@@ -44,9 +55,10 @@ function buildRecentActivityChatDeepLink(params: {
44
55
  function buildRecentActivityChatSystemTitle(params: {
45
56
  workstream: NormalizedWorkstream
46
57
  visibleAgentId: string
58
+ agentDisplayNamesById?: Partial<Record<string, string>>
47
59
  }): string {
48
60
  if (params.workstream.mode === 'direct') {
49
- return `Conversation with ${agentDisplayNames[params.visibleAgentId]}`
61
+ return `Conversation with ${resolveDisplayName(params.visibleAgentId, params.agentDisplayNamesById)}`
50
62
  }
51
63
 
52
64
  return params.workstream.title.trim() || 'Workstream update'
@@ -77,6 +89,7 @@ interface PostTurnSideEffectsParams {
77
89
  defaultLeadAgentId: string
78
90
  latestWorkstreamRecord: WorkstreamRecord
79
91
  isUserTurn: boolean
92
+ agentDisplayNamesById?: Partial<Record<string, string>>
80
93
  }
81
94
 
82
95
  export async function runPostTurnSideEffects(params: PostTurnSideEffectsParams): Promise<void> {
@@ -140,8 +153,9 @@ export async function runPostTurnSideEffects(params: PostTurnSideEffectsParams):
140
153
  title: buildRecentActivityChatSystemTitle({
141
154
  workstream: params.workstream,
142
155
  visibleAgentId: effectiveAgentId,
156
+ agentDisplayNamesById: params.agentDisplayNamesById,
143
157
  }),
144
- sourceLabel: agentDisplayNames[effectiveAgentId],
158
+ sourceLabel: resolveDisplayName(effectiveAgentId, params.agentDisplayNamesById),
145
159
  deepLink: buildRecentActivityChatDeepLink({
146
160
  workstream: params.workstream,
147
161
  workstreamId: params.workstreamIdString,
@@ -149,7 +163,7 @@ export async function runPostTurnSideEffects(params: PostTurnSideEffectsParams):
149
163
  }),
150
164
  metadata: {
151
165
  agentId: effectiveAgentId,
152
- agentName: agentDisplayNames[effectiveAgentId],
166
+ agentName: resolveDisplayName(effectiveAgentId, params.agentDisplayNamesById),
153
167
  workstreamId: params.workstreamIdString,
154
168
  workstreamTitle: params.latestWorkstreamRecord.title ?? params.workstream.title,
155
169
  workstreamMode: params.workstream.mode,
@@ -91,14 +91,25 @@ export interface CreateConsultTeamToolParams {
91
91
  systemWorkspaceDetails?: string
92
92
  getPreSeededMemoriesSection: (agentId: string) => Promise<string | undefined>
93
93
  retrievedKnowledgeSection?: string
94
+ displayNamesById?: Partial<Record<string, string>>
94
95
  abortSignal: AbortSignal
95
96
  participantRunner: TeamConsultationParticipantRunner
96
97
  onReadError?: (agentId: string, error: unknown) => void
97
98
  }
98
99
 
99
100
  export function createConsultTeamTool(params: CreateConsultTeamToolParams) {
101
+ const resolveDisplayName = (agentId: string) => {
102
+ if (params.displayNamesById && Object.hasOwn(params.displayNamesById, agentId)) {
103
+ const override = params.displayNamesById[agentId]
104
+ if (override !== undefined) {
105
+ return override
106
+ }
107
+ }
108
+
109
+ return agentDisplayNames[agentId] ?? agentId
110
+ }
100
111
  const participantNames = teamConsultParticipants
101
- .map((agentId) => agentDisplayNames[agentId] ?? agentId)
112
+ .map((agentId) => resolveDisplayName(agentId))
102
113
  .filter((value) => value.trim().length > 0)
103
114
  const participantSummary =
104
115
  participantNames.length > 0 ? participantNames.join(', ') : 'the configured specialist participants'
@@ -110,7 +121,7 @@ export function createConsultTeamTool(params: CreateConsultTeamToolParams) {
110
121
  const uploadMetadataText = buildReadableUploadMetadataText(params.availableUploads)
111
122
  const responses: ConsultTeamResultData['responses'] = teamConsultParticipants.map((agentId) => ({
112
123
  agentId,
113
- agentName: agentDisplayNames[agentId] ?? agentId,
124
+ agentName: resolveDisplayName(agentId),
114
125
  status: 'running',
115
126
  }))
116
127
  const queue: ConsultTeamResultData[] = []
@@ -125,7 +136,7 @@ export function createConsultTeamTool(params: CreateConsultTeamToolParams) {
125
136
  }
126
137
 
127
138
  const workerPromises = teamConsultParticipants.map(async (agentId, index) => {
128
- const agentName = agentDisplayNames[agentId] ?? agentId
139
+ const agentName = resolveDisplayName(agentId)
129
140
  const timedAbort = createTimedAbortSignal(params.abortSignal, TEAM_CONSULTATION_TIMEOUT_MS)
130
141
  let latestMessage: ChatMessage | null = null
131
142
 
@@ -21,7 +21,10 @@ import {
21
21
  buildOwnershipDispatchContextSection,
22
22
  buildOwnershipDispatchResponseGuard,
23
23
  } from '../runtime/agent-runtime-policy'
24
+ import { mergeInstructionSections } from '../runtime/instruction-sections'
24
25
  import { buildIndexedRepositoriesContext, getPluginService } from '../runtime/plugin-resolution'
26
+ import { getTurnHooks } from '../runtime/runtime-extensions'
27
+ import { asRecord, readInstructionSections, readOptionalString } from '../runtime/workstream-chat-helpers'
25
28
  import { nodeWorkspaceService } from './node-workspace.service'
26
29
  import type { PlanValidationIssueInput } from './plan-validator.service'
27
30
  import { WorkstreamSchema } from './workstream.types'
@@ -145,25 +148,47 @@ class AgentExecutorService {
145
148
  const mode = params.executionMode ?? 'linear'
146
149
 
147
150
  const dispatchMode = workstream.mode === 'group' ? 'fixedWorkstreamMode' : 'direct'
151
+ const dispatchInstructionSections = [
152
+ buildOwnershipDispatchContextSection({
153
+ node: params.nodeSpec,
154
+ resolvedInput: params.resolvedInput,
155
+ inputArtifacts: params.inputArtifacts,
156
+ upstreamHandoffs: params.context.upstreamHandoffs,
157
+ }),
158
+ ]
159
+ const turnHooks = getTurnHooks()
160
+ const agentResolution = asRecord(
161
+ await turnHooks.resolveAgent?.({
162
+ agentId,
163
+ mode: dispatchMode,
164
+ workstream,
165
+ workstreamRef,
166
+ orgRef: organizationRef,
167
+ userRef,
168
+ userName,
169
+ onboardingActive: false,
170
+ linearInstalled: Boolean(linearInstallation),
171
+ githubInstalled: Boolean(githubInstallation),
172
+ additionalInstructionSections: dispatchInstructionSections,
173
+ context: null,
174
+ }),
175
+ )
176
+ const resolvedAgentId = readOptionalString(agentResolution?.agentId) ?? agentId
148
177
  const runtimeConfig = getAgentRuntimeConfig({
149
- agentId,
178
+ agentId: resolvedAgentId,
150
179
  workstreamMode: workstream.mode,
151
180
  mode: dispatchMode,
152
181
  onboardingActive: false,
153
182
  linearInstalled: Boolean(linearInstallation),
154
- additionalInstructionSections: [
155
- buildOwnershipDispatchContextSection({
156
- node: params.nodeSpec,
157
- resolvedInput: params.resolvedInput,
158
- inputArtifacts: params.inputArtifacts,
159
- upstreamHandoffs: params.context.upstreamHandoffs,
160
- }),
161
- ],
183
+ additionalInstructionSections: mergeInstructionSections(
184
+ dispatchInstructionSections,
185
+ readInstructionSections(agentResolution?.additionalInstructionSections),
186
+ ),
162
187
  responseGuardSection: buildOwnershipDispatchResponseGuard({ node: params.nodeSpec, executionMode: mode }),
163
188
  }) as Record<string, unknown>
164
189
 
165
190
  const rawTools = (await buildAgentTools({
166
- agentId,
191
+ agentId: resolvedAgentId,
167
192
  orgId: organizationRef,
168
193
  userId: userRef,
169
194
  userName,
@@ -175,7 +200,7 @@ class AgentExecutorService {
175
200
  onboardingActive: false,
176
201
  githubInstalled: Boolean(githubInstallation),
177
202
  provideRepoTool: indexedRepoContext.provideRepoTool,
178
- defaultRepoSections: indexedRepoContext.defaultSectionsByAgent[agentId],
203
+ defaultRepoSections: indexedRepoContext.defaultSectionsByAgent[resolvedAgentId],
179
204
  memoryBlock: '',
180
205
  onAppendMemoryBlock: () => undefined,
181
206
  availableUploads: [],
@@ -183,9 +208,9 @@ class AgentExecutorService {
183
208
  })) as ToolSet
184
209
  const tools = applyToolPolicy(rawTools, params.nodeSpec)
185
210
 
186
- const agentFactory = createAgent[agentId] as ((...args: unknown[]) => unknown) | undefined
211
+ const agentFactory = createAgent[resolvedAgentId] as ((...args: unknown[]) => unknown) | undefined
187
212
  if (!agentFactory) {
188
- throw new Error(`Agent factory "${agentId}" is not registered.`)
213
+ throw new Error(`Agent factory "${resolvedAgentId}" is not registered.`)
189
214
  }
190
215
 
191
216
  const maxSteps = typeof runtimeConfig.maxSteps === 'number' ? runtimeConfig.maxSteps : 8
@@ -13,7 +13,6 @@ import type { PrepareStepFunction, StopCondition, ToolLoopAgent, ToolSet, UIMess
13
13
 
14
14
  import type { CoreWorkstreamProfile } from '../config/agent-defaults'
15
15
  import {
16
- agentDisplayNames,
17
16
  agentRoster,
18
17
  buildAgentTools,
19
18
  createAgent,
@@ -28,6 +27,7 @@ import { recordIdToString } from '../db/record-id'
28
27
  import { TABLES } from '../db/tables'
29
28
  import { enqueueContextCompaction } from '../queues/context-compaction.queue'
30
29
  import { enqueueWorkstreamTitleGeneration } from '../queues/title-generation.queue'
30
+ import { readRuntimeAgentIdentityOverrides, resolveRuntimeAgentDisplayName } from '../runtime/agent-identity-overrides'
31
31
  import { OWNERSHIP_DISPATCH_BLOCKED_TOOL_NAMES } from '../runtime/agent-runtime-policy'
32
32
  import { createAgentMessageMetadata, createServerRunAbortController } from '../runtime/agent-stream-helpers'
33
33
  import { hasApprovalRespondedParts } from '../runtime/approval-continuation'
@@ -260,6 +260,7 @@ async function streamAgentResponse(
260
260
  streamParams: StreamAgentResponseParams,
261
261
  ): Promise<ChatMessage> {
262
262
  const agentTimer = lotaDebugLogger.timer(`agent:${streamParams.agentId}`)
263
+ const agentIdentityOverrides = readRuntimeAgentIdentityOverrides(ctx.buildContextResult)
263
264
  // Skip full plan state during plan turns — the plan-turn sections already have the active node contract
264
265
  const executionPlanInstructionSections =
265
266
  streamParams.includeExecutionPlanTools === false ? undefined : await ctx.getExecutionPlanInstructionSections()
@@ -383,7 +384,10 @@ async function streamAgentResponse(
383
384
  originalMessages: streamParams.messages,
384
385
  sendReasoning: true,
385
386
  sendSources: true,
386
- messageMetadata: createAgentMessageMetadata({ agentId: resolvedAgentId, agentName: config.displayName as string }),
387
+ messageMetadata: createAgentMessageMetadata({
388
+ agentId: resolvedAgentId,
389
+ agentName: resolveRuntimeAgentDisplayName(agentIdentityOverrides, resolvedAgentId),
390
+ }),
387
391
  onFinish: ({ responseMessage: finishedResponseMessage }: { responseMessage: ChatMessage }) => {
388
392
  responseMessage = withMessageCreatedAt(finishedResponseMessage, Date.now())
389
393
  resolveFinishedStream()
@@ -735,6 +739,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
735
739
  originalMessages,
736
740
  run: async (writer?: UIMessageStreamWriter<ChatMessage>) => {
737
741
  const executeRun = async (leaseAbortSignal?: AbortSignal): Promise<PreparedWorkstreamTurnResult | void> => {
742
+ const agentIdentityOverrides = readRuntimeAgentIdentityOverrides(buildContextResult)
738
743
  const runTimer = lotaDebugLogger.timer('run')
739
744
  const serverRunId = Bun.randomUUIDv7()
740
745
  const runAbortSignals = leaseAbortSignal ? [params.abortSignal, leaseAbortSignal] : [params.abortSignal]
@@ -828,7 +833,6 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
828
833
  filterTools?: (tools: ToolSet) => ToolSet
829
834
  includeExecutionPlanTools?: boolean
830
835
  metadataPatch?: NonNullable<MessageMetadata>
831
- headless?: boolean
832
836
  }): Promise<ChatMessage> => {
833
837
  const visibleTimer = lotaDebugLogger.timer(`visible:${runParams.agentId}`)
834
838
  let runMemoryBlock = memoryBlock
@@ -864,7 +868,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
864
868
  skills: runParams.skills,
865
869
  additionalInstructionSections: runParams.additionalInstructionSections,
866
870
  includeExecutionPlanTools,
867
- writer: runParams.headless ? undefined : writer,
871
+ writer,
868
872
  })
869
873
 
870
874
  visibleTimer.step('stream-agent-response')
@@ -873,7 +877,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
873
877
  return commitAssistantResponse(
874
878
  responseMessage,
875
879
  runParams.agentId,
876
- agentDisplayNames[runParams.agentId] ?? runParams.agentId,
880
+ resolveRuntimeAgentDisplayName(agentIdentityOverrides, runParams.agentId),
877
881
  runParams.metadataPatch,
878
882
  )
879
883
  }
@@ -927,13 +931,13 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
927
931
  members,
928
932
  messageText,
929
933
  recentContext,
934
+ displayNamesById: agentIdentityOverrides.displayNamesById,
935
+ shortDisplayNamesById: agentIdentityOverrides.shortDisplayNamesById,
936
+ routingAliasesByAgentId: agentIdentityOverrides.routingAliasesByAgentId,
930
937
  })
931
938
  throwIfRunAborted()
932
939
 
933
- const runGroupAgent = async (
934
- agentId: string,
935
- options?: { routingContext?: string; headless?: boolean },
936
- ) => {
940
+ const runGroupAgent = async (agentId: string, options?: { routingContext?: string }) => {
937
941
  const additionalSections = [...(coreInstructionSections ?? []), ...hookInstructionSections]
938
942
  if (options?.routingContext) {
939
943
  additionalSections.push(`<routing-context>\n${options.routingContext}\n</routing-context>`)
@@ -948,7 +952,6 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
948
952
  mode: 'workstreamMode',
949
953
  skills: coreWorkstreamProfile?.skills ? [...coreWorkstreamProfile.skills] : undefined,
950
954
  additionalInstructionSections: additionalSections,
951
- headless: options?.headless,
952
955
  })
953
956
  }
954
957
 
@@ -965,8 +968,8 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
965
968
  respondedAgents.push(triageResult.agentId)
966
969
  throwIfRunAborted()
967
970
 
968
- // Follow-up specialists run headless, persist their own messages,
969
- // and are surfaced by transient events plus cache refresh.
971
+ // Follow-up specialists stream visibly in order so the user can
972
+ // watch each specialist reply instead of waiting for a persisted refresh.
970
973
  while (respondedAgents.length < 3) {
971
974
  const lastResponseText = extractMessageText(lastResponse).slice(0, 500)
972
975
  const checkResult = await checkForNextAgent({
@@ -975,6 +978,9 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
975
978
  messageText,
976
979
  respondedAgents,
977
980
  lastResponseSummary: lastResponseText,
981
+ displayNamesById: agentIdentityOverrides.displayNamesById,
982
+ shortDisplayNamesById: agentIdentityOverrides.shortDisplayNamesById,
983
+ routingAliasesByAgentId: agentIdentityOverrides.routingAliasesByAgentId,
978
984
  })
979
985
  throwIfRunAborted()
980
986
 
@@ -983,7 +989,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
983
989
  writeMultiAgentEvent(writer, {
984
990
  phase: 'waiting-for-agent',
985
991
  agentId: checkResult.agentId,
986
- agentName: agentDisplayNames[checkResult.agentId] ?? checkResult.agentId,
992
+ agentName: resolveRuntimeAgentDisplayName(agentIdentityOverrides, checkResult.agentId),
987
993
  note: checkResult.routingContext,
988
994
  })
989
995
 
@@ -1009,14 +1015,13 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1009
1015
 
1010
1016
  lastResponse = await runGroupAgent(checkResult.agentId, {
1011
1017
  routingContext: checkResult.routingContext,
1012
- headless: true,
1013
1018
  })
1014
1019
  respondedAgents.push(checkResult.agentId)
1015
1020
  throwIfRunAborted()
1016
1021
  writeMultiAgentEvent(writer, {
1017
1022
  phase: 'agent-message-persisted',
1018
1023
  agentId: checkResult.agentId,
1019
- agentName: agentDisplayNames[checkResult.agentId] ?? checkResult.agentId,
1024
+ agentName: resolveRuntimeAgentDisplayName(agentIdentityOverrides, checkResult.agentId),
1020
1025
  messageId: lastResponse.id,
1021
1026
  })
1022
1027
  }
@@ -1083,6 +1088,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1083
1088
  defaultLeadAgentId,
1084
1089
  latestWorkstreamRecord,
1085
1090
  isUserTurn: params.kind === 'userTurn',
1091
+ agentDisplayNamesById: agentIdentityOverrides.displayNamesById,
1086
1092
  })
1087
1093
  }
1088
1094
 
@@ -3,13 +3,7 @@ import { z } from 'zod'
3
3
 
4
4
  import { aiGatewayChatModel } from '../ai-gateway/ai-gateway'
5
5
  import { buildAiGatewayDirectCacheHeaders } from '../ai-gateway/cache-headers'
6
- import {
7
- agentDescriptions,
8
- agentDisplayNames,
9
- agentShortDisplayNames,
10
- resolveAgentNameAlias,
11
- routerModelId,
12
- } from '../config/agent-defaults'
6
+ import { agentDescriptions, agentDisplayNames, agentShortDisplayNames, routerModelId } from '../config/agent-defaults'
13
7
 
14
8
  // ---------------------------------------------------------------------------
15
9
  // Schemas
@@ -23,6 +17,8 @@ const CheckResultSchema = z.object({
23
17
  routingContext: z.string().optional(),
24
18
  })
25
19
 
20
+ const ROUTER_OUTPUT_PREVIEW_CHARS = 300
21
+
26
22
  export type RouterTriageResult = z.infer<typeof TriageResultSchema>
27
23
  export type RouterCheckResult = z.infer<typeof CheckResultSchema>
28
24
 
@@ -30,10 +26,45 @@ export type RouterCheckResult = z.infer<typeof CheckResultSchema>
30
26
  // Helpers
31
27
  // ---------------------------------------------------------------------------
32
28
 
33
- function buildMembersDescription(members: readonly string[]): string {
29
+ interface RouterDisplayOptions {
30
+ displayNamesById?: Partial<Record<string, string>>
31
+ shortDisplayNamesById?: Partial<Record<string, string>>
32
+ routingAliasesByAgentId?: Partial<Record<string, string[]>>
33
+ }
34
+
35
+ function readStringOverride(record: Partial<Record<string, string>> | undefined, key: string): string | undefined {
36
+ return record && Object.hasOwn(record, key) ? record[key] : undefined
37
+ }
38
+
39
+ function readStringArrayOverride(
40
+ record: Partial<Record<string, string[]>> | undefined,
41
+ key: string,
42
+ ): string[] | undefined {
43
+ return record && Object.hasOwn(record, key) ? record[key] : undefined
44
+ }
45
+
46
+ function readDisplayName(agentId: string, options?: RouterDisplayOptions): string {
47
+ const override = readStringOverride(options?.displayNamesById, agentId)
48
+ if (override !== undefined) {
49
+ return override
50
+ }
51
+
52
+ return agentDisplayNames[agentId] ?? agentId
53
+ }
54
+
55
+ function readShortDisplayName(agentId: string, options?: RouterDisplayOptions): string {
56
+ const override = readStringOverride(options?.shortDisplayNamesById, agentId)
57
+ if (override !== undefined) {
58
+ return override
59
+ }
60
+
61
+ return agentShortDisplayNames[agentId] ?? readDisplayName(agentId, options)
62
+ }
63
+
64
+ function buildMembersDescription(members: readonly string[], options?: RouterDisplayOptions): string {
34
65
  return members
35
66
  .map((id) => {
36
- const display = agentDisplayNames[id] ?? id
67
+ const display = readDisplayName(id, options)
37
68
  const desc = agentDescriptions[id] ?? ''
38
69
  return `- ${display} (id: ${id}): ${desc}`
39
70
  })
@@ -44,12 +75,16 @@ function escapeRegex(value: string): string {
44
75
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
45
76
  }
46
77
 
47
- function buildExplicitAgentRoutingContext(agentId: string): string {
48
- const displayName = agentDisplayNames[agentId] ?? agentId
78
+ function buildExplicitAgentRoutingContext(agentId: string, options?: RouterDisplayOptions): string {
79
+ const displayName = readDisplayName(agentId, options)
49
80
  return `Respond directly to the part of the user's request explicitly addressed to ${displayName}.`
50
81
  }
51
82
 
52
- function extractExplicitAgentTargets(messageText: string, members: readonly string[]): string[] {
83
+ function extractExplicitAgentTargets(
84
+ messageText: string,
85
+ members: readonly string[],
86
+ options?: RouterDisplayOptions,
87
+ ): string[] {
53
88
  const normalizedMessage = messageText.trim()
54
89
  if (!normalizedMessage) return []
55
90
 
@@ -57,7 +92,14 @@ function extractExplicitAgentTargets(messageText: string, members: readonly stri
57
92
  const aliases = new Map<string, string>()
58
93
 
59
94
  for (const member of members) {
60
- for (const rawAlias of [member, agentDisplayNames[member], agentShortDisplayNames[member]]) {
95
+ for (const rawAlias of [
96
+ member,
97
+ agentDisplayNames[member],
98
+ agentShortDisplayNames[member],
99
+ readDisplayName(member, options),
100
+ readShortDisplayName(member, options),
101
+ ...(readStringArrayOverride(options?.routingAliasesByAgentId, member) ?? []),
102
+ ]) {
61
103
  if (typeof rawAlias !== 'string') continue
62
104
  const alias = rawAlias.trim().toLowerCase()
63
105
  if (!alias) continue
@@ -70,13 +112,13 @@ function extractExplicitAgentTargets(messageText: string, members: readonly stri
70
112
  const orderedAliases = [...aliases.keys()].sort((left, right) => right.length - left.length)
71
113
  const matches: Array<{ agentId: string; index: number }> = []
72
114
  const directAddressRegex = new RegExp(
73
- `(^|[^\\w@])(?<alias>${orderedAliases.map(escapeRegex).join('|')})(?=\\s*(?::|\\-|—))`,
115
+ `(^|[\\s([{>,"'])(?<alias>${orderedAliases.map(escapeRegex).join('|')})(?=\\s*(?::|[—–-](?=\\s)))`,
74
116
  'gi',
75
117
  )
76
118
 
77
119
  for (const match of normalizedMessage.matchAll(directAddressRegex)) {
78
120
  const alias = match.groups?.alias
79
- const agentId = alias ? resolveAgentNameAlias(alias) : undefined
121
+ const agentId = alias ? aliases.get(alias.trim().toLowerCase()) : undefined
80
122
  if (!agentId || !memberSet.has(agentId)) continue
81
123
 
82
124
  const prefix = match[1]
@@ -125,6 +167,12 @@ function extractResultText(result: { text?: string; reasoning?: unknown }): stri
125
167
  return ''
126
168
  }
127
169
 
170
+ function logRouterRaw(label: 'triage' | 'check', text: string): void {
171
+ const preview = text.trim().slice(0, ROUTER_OUTPUT_PREVIEW_CHARS)
172
+ if (!preview) return
173
+ console.log(`[workstream-router] ${label} raw:`, preview)
174
+ }
175
+
128
176
  // ---------------------------------------------------------------------------
129
177
  // Prompts
130
178
  // ---------------------------------------------------------------------------
@@ -166,7 +214,7 @@ function createRouterAgent(systemPrompt: string) {
166
214
  id: 'workstream-router',
167
215
  model: aiGatewayChatModel(modelId),
168
216
  headers: buildAiGatewayDirectCacheHeaders('workstream-router'),
169
- providerOptions: { openai: { reasoningEffort: 'high' } },
217
+ providerOptions: { openai: { reasoningEffort: 'low' } },
170
218
  instructions: systemPrompt,
171
219
  maxOutputTokens: 256,
172
220
  })
@@ -177,14 +225,25 @@ export async function triageWorkstreamMessage(params: {
177
225
  members: readonly string[]
178
226
  messageText: string
179
227
  recentContext?: string
228
+ displayNamesById?: Partial<Record<string, string>>
229
+ shortDisplayNamesById?: Partial<Record<string, string>>
230
+ routingAliasesByAgentId?: Partial<Record<string, string[]>>
180
231
  }): Promise<RouterTriageResult | null> {
181
- const explicitTargets = extractExplicitAgentTargets(params.messageText, params.members)
232
+ const displayOptions: RouterDisplayOptions = {
233
+ displayNamesById: params.displayNamesById,
234
+ shortDisplayNamesById: params.shortDisplayNamesById,
235
+ routingAliasesByAgentId: params.routingAliasesByAgentId,
236
+ }
237
+ const explicitTargets = extractExplicitAgentTargets(params.messageText, params.members, displayOptions)
182
238
  const firstExplicitTarget = explicitTargets[0]
183
239
  if (firstExplicitTarget) {
184
- return { agentId: firstExplicitTarget, routingContext: buildExplicitAgentRoutingContext(firstExplicitTarget) }
240
+ return {
241
+ agentId: firstExplicitTarget,
242
+ routingContext: buildExplicitAgentRoutingContext(firstExplicitTarget, displayOptions),
243
+ }
185
244
  }
186
245
 
187
- const membersDesc = buildMembersDescription(params.members)
246
+ const membersDesc = buildMembersDescription(params.members, displayOptions)
188
247
  const prompt = [
189
248
  `Workstream: "${params.workstreamTitle}"`,
190
249
  `Members:\n${membersDesc}`,
@@ -204,8 +263,14 @@ export async function triageWorkstreamMessage(params: {
204
263
  }
205
264
 
206
265
  const effectiveText = extractResultText(result as { text?: string; reasoning?: unknown })
207
- console.log('[workstream-router] triage raw:', effectiveText.slice(0, 300))
266
+ logRouterRaw('triage', effectiveText)
208
267
  const json = extractJson(effectiveText)
268
+ if (json === null) {
269
+ if (effectiveText.trim()) {
270
+ console.log('[workstream-router] triage ignored non-json output')
271
+ }
272
+ return null
273
+ }
209
274
  const parsed = TriageResultSchema.safeParse(json)
210
275
  if (!parsed.success) {
211
276
  console.log('[workstream-router] triage parse failed:', JSON.stringify(parsed.error.issues))
@@ -230,22 +295,30 @@ export async function checkForNextAgent(params: {
230
295
  messageText: string
231
296
  respondedAgents: string[]
232
297
  lastResponseSummary: string
298
+ displayNamesById?: Partial<Record<string, string>>
299
+ shortDisplayNamesById?: Partial<Record<string, string>>
300
+ routingAliasesByAgentId?: Partial<Record<string, string[]>>
233
301
  }): Promise<RouterCheckResult> {
234
- const explicitTargets = extractExplicitAgentTargets(params.messageText, params.members)
302
+ const displayOptions: RouterDisplayOptions = {
303
+ displayNamesById: params.displayNamesById,
304
+ shortDisplayNamesById: params.shortDisplayNamesById,
305
+ routingAliasesByAgentId: params.routingAliasesByAgentId,
306
+ }
307
+ const explicitTargets = extractExplicitAgentTargets(params.messageText, params.members, displayOptions)
235
308
  const nextExplicitTarget = explicitTargets.find((agentId) => !params.respondedAgents.includes(agentId))
236
309
  if (nextExplicitTarget) {
237
310
  return {
238
311
  done: false,
239
312
  agentId: nextExplicitTarget,
240
- routingContext: buildExplicitAgentRoutingContext(nextExplicitTarget),
313
+ routingContext: buildExplicitAgentRoutingContext(nextExplicitTarget, displayOptions),
241
314
  }
242
315
  }
243
316
 
244
317
  const remainingMembers = params.members.filter((id) => !params.respondedAgents.includes(id))
245
318
  if (remainingMembers.length === 0) return { done: true }
246
319
 
247
- const membersDesc = buildMembersDescription(remainingMembers)
248
- const respondedList = params.respondedAgents.map((id) => agentDisplayNames[id] ?? id).join(', ')
320
+ const membersDesc = buildMembersDescription(remainingMembers, displayOptions)
321
+ const respondedList = params.respondedAgents.map((id) => readDisplayName(id, displayOptions)).join(', ')
249
322
 
250
323
  const prompt = [
251
324
  `Workstream: "${params.workstreamTitle}"`,
@@ -265,8 +338,14 @@ export async function checkForNextAgent(params: {
265
338
  }
266
339
 
267
340
  const effectiveText = extractResultText(result as { text?: string; reasoning?: unknown })
268
- console.log('[workstream-router] check raw:', effectiveText.slice(0, 300))
341
+ logRouterRaw('check', effectiveText)
269
342
  const json = extractJson(effectiveText)
343
+ if (json === null) {
344
+ if (effectiveText.trim()) {
345
+ console.log('[workstream-router] check ignored non-json output')
346
+ }
347
+ return { done: true }
348
+ }
270
349
  const parsed = CheckResultSchema.safeParse(json)
271
350
  if (!parsed.success) {
272
351
  console.log('[workstream-router] check parse failed:', JSON.stringify(parsed.error.issues))
@@ -68,10 +68,16 @@ export function createExecutionPlanTool(params: {
68
68
  'create-project': async () => {
69
69
  const targetWorkstream = targetWorkstreamId
70
70
  ? await resolvedWsService.getWorkstream(targetWorkstreamId)
71
- : await resolvedWsService.createWorkstream(params.userId, params.orgId, {
72
- title: projectTitle!,
73
- mode: 'group',
74
- })
71
+ : await (() => {
72
+ if (!projectTitle) {
73
+ throw new Error('projectTitle is required when action is "create-project".')
74
+ }
75
+
76
+ return resolvedWsService.createWorkstream(params.userId, params.orgId, {
77
+ title: projectTitle,
78
+ mode: 'group',
79
+ })
80
+ })()
75
81
 
76
82
  if (targetWorkstream.organizationId !== recordIdToString(params.orgId, TABLES.ORGANIZATION)) {
77
83
  throw new Error('Target workstream belongs to a different organization.')
@@ -110,19 +116,27 @@ export function createExecutionPlanTool(params: {
110
116
  },
111
117
 
112
118
  replace: async () => {
119
+ if (!runId || !reason) {
120
+ throw new Error('runId and reason are required when action is "replace".')
121
+ }
122
+
113
123
  return await resolvedEpService.replacePlan({
114
124
  organizationId: params.orgId,
115
125
  workstreamId: params.workstreamId,
116
126
  leadAgentId: params.agentId,
117
- input: { runId: runId!, reason: reason!, ...draft },
127
+ input: { runId, reason, ...draft },
118
128
  })
119
129
  },
120
130
 
121
131
  resume: async () => {
132
+ if (!runId) {
133
+ throw new Error('runId is required when action is "resume".')
134
+ }
135
+
122
136
  return await resolvedEpService.resumeRun({
123
137
  workstreamId: params.workstreamId,
124
138
  emittedBy: params.agentId,
125
- input: { runId: runId! },
139
+ input: { runId },
126
140
  })
127
141
  },
128
142
  }
@@ -1,8 +1,8 @@
1
1
  import { aiGatewayChatModel } from '../ai-gateway/ai-gateway'
2
2
  import { buildAiGatewayStrictSemanticCacheHeaders } from '../ai-gateway/cache-headers'
3
3
  import {
4
- OPENROUTER_HIGH_REASONING_PROVIDER_OPTIONS,
5
4
  OPENROUTER_FAST_REASONING_MODEL_ID,
5
+ OPENROUTER_LOW_REASONING_PROVIDER_OPTIONS,
6
6
  } from '../config/model-constants'
7
7
  import { createDelegatedAgentTool } from '../system-agents/delegated-agent-factory'
8
8
  import { RESEARCHER_PROMPT } from '../system-agents/researcher.agent'
@@ -14,7 +14,7 @@ export const researchTopicTool = createDelegatedAgentTool({
14
14
  description:
15
15
  'Delegate a research task to a dedicated research agent that searches the web, fetches pages, and returns a synthesized markdown report.',
16
16
  model: () => aiGatewayChatModel(OPENROUTER_FAST_REASONING_MODEL_ID),
17
- providerOptions: OPENROUTER_HIGH_REASONING_PROVIDER_OPTIONS,
17
+ providerOptions: OPENROUTER_LOW_REASONING_PROVIDER_OPTIONS,
18
18
  headers: buildAiGatewayStrictSemanticCacheHeaders('researchTopic'),
19
19
  instructions: RESEARCHER_PROMPT,
20
20
  tools: { searchWeb: searchWebTool.create(), fetchWebpage: fetchWebpageTool.create() },
@@ -7,12 +7,14 @@ import { aiLogger } from '../config/logger'
7
7
  import type { RecordIdRef } from '../db/record-id'
8
8
  import { recordIdToString } from '../db/record-id'
9
9
  import { TABLES } from '../db/tables'
10
+ import { readRuntimeAgentIdentityOverrides } from '../runtime/agent-identity-overrides'
10
11
  import { mergeInstructionSections } from '../runtime/instruction-sections'
11
- import { getRuntimeAdapters } from '../runtime/runtime-extensions'
12
+ import { getRuntimeAdapters, getTurnHooks } from '../runtime/runtime-extensions'
12
13
  import type { LotaRuntimeTeamThinkToolsParams } from '../runtime/runtime-extensions'
13
14
  import { createConsultTeamTool as createConsultTeamToolSdk } from '../runtime/team-consultation-orchestrator'
14
15
  import type { DefaultRepoSections, TeamConsultationParticipantRunner } from '../runtime/team-consultation-orchestrator'
15
16
  import { buildTeamConsultationResponseGuard } from '../runtime/team-consultation-prompts'
17
+ import { asRecord, readInstructionSections, readOptionalString } from '../runtime/workstream-chat-helpers'
16
18
  import type { ReadableUploadMetadata } from '../services/attachment.service'
17
19
 
18
20
  async function buildTeamThinkAgentTools(
@@ -49,11 +51,33 @@ export function createTeamThinkTool(params: {
49
51
  toolProviders?: ToolSet
50
52
  abortSignal: AbortSignal
51
53
  }) {
54
+ const agentIdentityOverrides = readRuntimeAgentIdentityOverrides(
55
+ (params.context as Record<string, unknown> | null | undefined) ?? null,
56
+ )
52
57
  const participantRunner: TeamConsultationParticipantRunner = {
53
58
  async buildParticipantAgent(agentId, runParams) {
54
59
  const dynamicInstructionSections = await params.getAdditionalInstructionSections?.()
60
+ const agentResolution = asRecord(
61
+ await getTurnHooks().resolveAgent?.({
62
+ agentId,
63
+ mode: 'fixedWorkstreamMode',
64
+ workstream: null,
65
+ workstreamRef: params.workstreamId,
66
+ orgRef: params.orgId,
67
+ userRef: params.userId,
68
+ onboardingActive: false,
69
+ linearInstalled: false,
70
+ githubInstalled: params.githubInstalled,
71
+ additionalInstructionSections: mergeInstructionSections(
72
+ dynamicInstructionSections,
73
+ params.additionalInstructionSections,
74
+ ),
75
+ context: (params.context as Record<string, unknown> | null | undefined) ?? null,
76
+ }),
77
+ )
78
+ const resolvedAgentId = readOptionalString(agentResolution?.agentId) ?? agentId
55
79
  const config = getAgentRuntimeConfig({
56
- agentId,
80
+ agentId: resolvedAgentId,
57
81
  workstreamMode: 'group' as const,
58
82
  mode: 'fixedWorkstreamMode',
59
83
  onboardingActive: false,
@@ -64,24 +88,25 @@ export function createTeamThinkTool(params: {
64
88
  additionalInstructionSections: mergeInstructionSections(
65
89
  dynamicInstructionSections,
66
90
  params.additionalInstructionSections,
91
+ readInstructionSections(agentResolution?.additionalInstructionSections),
67
92
  ),
68
- responseGuardSection: buildTeamConsultationResponseGuard({ agentId, task: runParams.task }),
93
+ responseGuardSection: buildTeamConsultationResponseGuard({ agentId: resolvedAgentId, task: runParams.task }),
69
94
  })
70
95
  const { tools } = await buildTeamThinkAgentTools({
71
- agentId,
96
+ agentId: resolvedAgentId,
72
97
  workspaceId: params.orgId,
73
98
  userId: params.userId,
74
99
  workspaceIdString: recordIdToString(params.orgId, TABLES.ORGANIZATION),
75
100
  workstreamId: params.workstreamId,
76
101
  githubInstalled: params.githubInstalled,
77
- provideRepoTool: agentId !== 'mentor' && params.provideRepoTool,
102
+ provideRepoTool: resolvedAgentId !== 'mentor' && params.provideRepoTool,
78
103
  availableUploads: params.availableUploads,
79
- defaultRepoSections: params.defaultRepoSectionsByAgent[agentId],
104
+ defaultRepoSections: params.defaultRepoSectionsByAgent[resolvedAgentId],
80
105
  context: params.context,
81
106
  toolProviders: params.toolProviders,
82
107
  })
83
108
  const agentConfig = config as Record<string, unknown>
84
- const agentId_ = typeof agentConfig.id === 'string' ? agentConfig.id : agentId
109
+ const agentId_ = typeof agentConfig.id === 'string' ? agentConfig.id : resolvedAgentId
85
110
  const configuredMaxSteps = typeof agentConfig.maxSteps === 'number' ? agentConfig.maxSteps : 10
86
111
  const maxSteps = Math.min(configuredMaxSteps, TEAM_THINK_AGENT_MAX_STEPS)
87
112
  const agent = createAgent[agentId_]({
@@ -117,6 +142,7 @@ export function createTeamThinkTool(params: {
117
142
  systemWorkspaceDetails: params.systemWorkspaceDetails,
118
143
  getPreSeededMemoriesSection: params.getPreSeededMemoriesSection,
119
144
  retrievedKnowledgeSection: params.retrievedKnowledgeSection,
145
+ displayNamesById: agentIdentityOverrides.displayNamesById,
120
146
  abortSignal: params.abortSignal,
121
147
  participantRunner,
122
148
  onReadError: (agentId, error) => {