@elevasis/core 0.48.0 → 0.48.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,116 +1,165 @@
1
- /**
2
- * Action Phase Processor
3
- * Orchestrates execution of LLM-planned actions
4
- */
5
-
6
- import type { IterationContext } from '../core/types'
7
- import type { LLMIterationResponse } from '../reasoning/types'
8
- import type { AgentAction, ToolCallAction } from './types'
9
- import { executeToolCall } from './executor'
10
- import { executeNavigateKnowledge } from './navigate-knowledge-executor'
11
-
12
- /**
13
- * Validate action sequence for correctness
14
- * Ensures no invalid patterns that would cause undefined behavior
15
- *
16
- * Rules:
17
- * - No multiple 'complete' actions
18
- * - 'complete' cannot mix with 'navigate-knowledge' (loading knowledge without
19
- * reasoning about it is always wasteful the LLM should iterate instead)
20
- * - 'complete' CAN mix with 'tool-call' (side-effect tools like navigate_user,
21
- * update_filters are fire-and-forget tool calls execute first via
22
- * Promise.allSettled before completion is signaled)
23
- * - 'message' actions ARE allowed before 'complete' (user-facing communication)
24
- *
25
- * @param actions - Action array to validate
26
- * @throws Error if validation fails
27
- */
28
- function validateActionSequence(actions: AgentAction[]): void {
29
- // Count completion actions
30
- const completeActions = actions.filter((a) => a.type === 'complete')
31
-
32
- // Rule 1: No duplicate completions
33
- if (completeActions.length > 1) {
34
- throw new Error('Multiple complete actions not allowed in single iteration')
35
- }
36
-
37
- // Rule 2: Completion cannot mix with navigate-knowledge
38
- // (tool-call + complete is allowed side-effect tools execute before completion)
39
- if (completeActions.length === 1) {
40
- const hasNavigateKnowledge = actions.some((a) => a.type === 'navigate-knowledge')
41
- if (hasNavigateKnowledge) {
42
- throw new Error('Complete action cannot mix with navigate-knowledge actions')
43
- }
44
- }
45
- }
46
-
47
- /**
48
- * Process all actions from LLM response
49
- * Executes tool calls in parallel, handles completion, and messages sequentially
50
- *
51
- * Tool calls are parallelized using Promise.allSettled() for performance:
52
- * - Independent tool calls execute concurrently (up to 5x faster)
53
- * - Partial failures don't abort successful tools
54
- * - Results added to memory as they complete
55
- *
56
- * Other actions (navigate-knowledge, message, complete) remain sequential
57
- * because they are order-dependent.
58
- *
59
- * @param iterationContext - Agent execution context
60
- * @param response - LLM response with actions to execute
61
- * @returns Object with shouldComplete flag (no finalAnswer - generated in completion phase)
62
- */
63
- export async function processActions(
64
- iterationContext: IterationContext,
65
- response: LLMIterationResponse
66
- ): Promise<{ shouldComplete: boolean }> {
67
- // Validate action sequence before processing
68
- validateActionSequence(response.nextActions)
69
-
70
- let shouldComplete = false
71
-
72
- // Group actions by parallelizability
73
- const toolCalls: ToolCallAction[] = []
74
- const otherActions: AgentAction[] = []
75
-
76
- for (const action of response.nextActions) {
77
- if (action.type === 'tool-call') {
78
- toolCalls.push(action)
79
- } else {
80
- otherActions.push(action)
81
- }
82
- }
83
-
84
- // Execute tool calls in parallel (if any)
85
- // Uses Promise.allSettled() for partial success handling:
86
- // - If 4/5 tools succeed, we get those results
87
- // - Each tool handles errors independently and adds to memory
88
- // - No result aggregation needed (each tool call adds to memory directly)
89
- if (toolCalls.length > 0) {
90
- await Promise.allSettled(toolCalls.map((action) => executeToolCall(iterationContext, action)))
91
- }
92
-
93
- // Execute other actions sequentially (order matters)
94
- for (const action of otherActions) {
95
- switch (action.type) {
96
- case 'navigate-knowledge':
97
- await executeNavigateKnowledge(iterationContext, action)
98
- break
99
-
100
- case 'complete':
101
- shouldComplete = true
102
- break
103
-
104
- case 'message': {
105
- // Emit assistant message event (immediate persistence + streaming)
106
- await iterationContext.executionContext.onMessageEvent?.({
107
- type: 'assistant_message',
108
- text: action.text
109
- })
110
- break
111
- }
112
- }
113
- }
114
-
115
- return { shouldComplete }
116
- }
1
+ /**
2
+ * Action Phase Processor
3
+ * Orchestrates execution of LLM-planned actions
4
+ */
5
+
6
+ import type { IterationContext } from '../core/types'
7
+ import type { LLMIterationResponse } from '../reasoning/types'
8
+ import type { AgentAction, ToolCallAction } from './types'
9
+ import { executeToolCall } from './executor'
10
+ import { executeNavigateKnowledge } from './navigate-knowledge-executor'
11
+
12
+ /**
13
+ * Validate action sequence for correctness
14
+ * Ensures no invalid patterns that would cause undefined behavior
15
+ *
16
+ * Rules:
17
+ * - No multiple 'complete' actions
18
+ * - 'complete' cannot mix with 'navigate-knowledge' (loading knowledge without
19
+ * reasoning about it is always wasteful - the LLM should iterate instead)
20
+ * - 'complete' CAN mix with 'tool-call' (side-effect tools like navigate_user,
21
+ * update_filters are fire-and-forget - tool calls execute first via
22
+ * Promise.allSettled before completion is signaled)
23
+ * - 'message' actions ARE allowed before 'complete' (user-facing communication)
24
+ *
25
+ * @param actions - Action array to validate
26
+ * @throws Error if validation fails
27
+ */
28
+ function validateActionSequence(actions: AgentAction[]): void {
29
+ // Count completion actions
30
+ const completeActions = actions.filter((a) => a.type === 'complete')
31
+
32
+ // Rule 1: No duplicate completions
33
+ if (completeActions.length > 1) {
34
+ throw new Error('Multiple complete actions not allowed in single iteration')
35
+ }
36
+
37
+ // Rule 2: Completion cannot mix with navigate-knowledge
38
+ // (tool-call + complete is allowed - side-effect tools execute before completion)
39
+ if (completeActions.length === 1) {
40
+ const hasNavigateKnowledge = actions.some((a) => a.type === 'navigate-knowledge')
41
+ if (hasNavigateKnowledge) {
42
+ throw new Error('Complete action cannot mix with navigate-knowledge actions')
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Collapse multiple session-visible messages into one assistant message.
49
+ *
50
+ * Session turns should produce at most one visible assistant bubble per
51
+ * iteration. Non-session executions keep the historical behavior because their
52
+ * messages are not rendered as conversational bubbles.
53
+ */
54
+ function normalizeSessionMessages(actions: AgentAction[], sessionCapable: boolean): AgentAction[] {
55
+ if (!sessionCapable) {
56
+ return actions
57
+ }
58
+
59
+ const messages = actions.filter((action) => action.type === 'message')
60
+ if (messages.length <= 1) {
61
+ return actions
62
+ }
63
+
64
+ const collapsedText = messages.map((message) => message.text).join('\n\n')
65
+ const collapsedMessage: AgentAction = { type: 'message', text: collapsedText }
66
+ let emittedCollapsedMessage = false
67
+
68
+ return actions.flatMap((action): AgentAction[] => {
69
+ if (action.type !== 'message') {
70
+ return [action]
71
+ }
72
+
73
+ if (emittedCollapsedMessage) {
74
+ return []
75
+ }
76
+
77
+ emittedCollapsedMessage = true
78
+ return [collapsedMessage]
79
+ })
80
+ }
81
+
82
+ /**
83
+ * Process all actions from LLM response
84
+ * Executes tool calls in parallel, handles completion, and messages sequentially
85
+ *
86
+ * Tool calls are parallelized using Promise.allSettled() for performance:
87
+ * - Independent tool calls execute concurrently (up to 5x faster)
88
+ * - Partial failures don't abort successful tools
89
+ * - Results added to memory as they complete
90
+ *
91
+ * Other actions (navigate-knowledge, message, complete) remain sequential
92
+ * because they are order-dependent.
93
+ *
94
+ * @param iterationContext - Agent execution context
95
+ * @param response - LLM response with actions to execute
96
+ * @returns Object with shouldComplete flag (no finalAnswer - generated in completion phase)
97
+ */
98
+ export async function processActions(
99
+ iterationContext: IterationContext,
100
+ response: LLMIterationResponse
101
+ ): Promise<{ shouldComplete: boolean }> {
102
+ // Validate action sequence before processing
103
+ validateActionSequence(response.nextActions)
104
+ const normalizedActions = normalizeSessionMessages(response.nextActions, !!iterationContext.config.sessionCapable)
105
+
106
+ let shouldComplete = false
107
+
108
+ // Group actions by parallelizability
109
+ const toolCalls: ToolCallAction[] = []
110
+ const otherActions: AgentAction[] = []
111
+
112
+ for (const action of normalizedActions) {
113
+ if (action.type === 'tool-call') {
114
+ toolCalls.push(action)
115
+ } else {
116
+ otherActions.push(action)
117
+ }
118
+ }
119
+
120
+ // Execute tool calls in parallel (if any)
121
+ // Uses Promise.allSettled() for partial success handling:
122
+ // - If 4/5 tools succeed, we get those results
123
+ // - Each tool handles errors independently and adds to memory
124
+ // - No result aggregation needed (each tool call adds to memory directly)
125
+ if (toolCalls.length > 0) {
126
+ await Promise.allSettled(toolCalls.map((action) => executeToolCall(iterationContext, action)))
127
+ }
128
+
129
+ // Execute other actions sequentially (order matters)
130
+ for (const action of otherActions) {
131
+ switch (action.type) {
132
+ case 'navigate-knowledge':
133
+ await executeNavigateKnowledge(iterationContext, action)
134
+ break
135
+
136
+ case 'complete':
137
+ shouldComplete = true
138
+ break
139
+
140
+ case 'message': {
141
+ // Emit assistant message event (immediate persistence + streaming)
142
+ await iterationContext.executionContext.onMessageEvent?.({
143
+ type: 'assistant_message',
144
+ text: action.text
145
+ })
146
+ break
147
+ }
148
+ }
149
+ }
150
+
151
+ // A user-facing message with no tool calls and no explicit completion ends a
152
+ // conversational turn. Without this, a tool-less sessionCapable agent loops and
153
+ // emits multiple message bubbles for a single user turn when the LLM omits `complete`.
154
+ if (
155
+ !shouldComplete &&
156
+ iterationContext.config.sessionCapable &&
157
+ toolCalls.length === 0 &&
158
+ normalizedActions.some((a) => a.type === 'message') &&
159
+ !normalizedActions.some((a) => a.type === 'navigate-knowledge')
160
+ ) {
161
+ shouldComplete = true
162
+ }
163
+
164
+ return { shouldComplete }
165
+ }
@@ -86,7 +86,7 @@ describe('buildReasoningRequest', () => {
86
86
  })
87
87
  })
88
88
 
89
- describe('session capability', () => {
89
+ describe('session capability', () => {
90
90
  it('should set includeMessageAction to false when sessionCapable is not set', () => {
91
91
  const context = createMockContext()
92
92
  const request = buildReasoningRequest(context)
@@ -113,7 +113,7 @@ describe('buildReasoningRequest', () => {
113
113
  expect(request.includeMessageAction).toBe(true)
114
114
  })
115
115
 
116
- it('should set includeMessageAction to false when sessionCapable is false', () => {
116
+ it('should set includeMessageAction to false when sessionCapable is false', () => {
117
117
  const context = createMockContext({
118
118
  config: {
119
119
  type: 'agent',
@@ -128,12 +128,95 @@ describe('buildReasoningRequest', () => {
128
128
  }
129
129
  })
130
130
  const request = buildReasoningRequest(context)
131
-
132
- expect(request.includeMessageAction).toBe(false)
133
- })
134
- })
135
-
136
- describe('security prompt', () => {
131
+
132
+ expect(request.includeMessageAction).toBe(false)
133
+ })
134
+
135
+ it('should explain session message normalization and completion pairing', () => {
136
+ const context = createMockContext({
137
+ config: {
138
+ type: 'agent',
139
+ kind: 'utility',
140
+ resourceId: 'test-agent',
141
+ name: 'Test Agent',
142
+ description: 'Test agent',
143
+ version: '1.0.0',
144
+ status: 'dev',
145
+ systemPrompt: 'You are a test agent',
146
+ sessionCapable: true
147
+ }
148
+ })
149
+ const request = buildReasoningRequest(context)
150
+
151
+ expect(request.systemPrompt).toContain('Send at most one message per iteration')
152
+ expect(request.systemPrompt).toContain('Multiple messages in a session turn are collapsed into one visible assistant message')
153
+ expect(request.systemPrompt).toContain('send message + complete in the SAME iteration')
154
+ })
155
+ })
156
+
157
+ describe('action completion prompt contract', () => {
158
+ it('should allow complete with fire-and-forget tool calls while blocking navigate-knowledge completion', () => {
159
+ const mockTool = {
160
+ name: 'navigate_user',
161
+ description: 'Navigate the UI',
162
+ inputSchema: z.object({ path: z.string() }),
163
+ outputSchema: z.object({ ok: z.boolean() }),
164
+ execute: async () => ({ ok: true })
165
+ }
166
+
167
+ const toolRegistry = new Map()
168
+ toolRegistry.set('navigate_user', mockTool)
169
+
170
+ const context = createMockContext({ toolRegistry })
171
+ const request = buildReasoningRequest(context)
172
+
173
+ expect(request.systemPrompt).toContain('"complete" CAN mix with fire-and-forget tool-call actions')
174
+ expect(request.systemPrompt).toContain('"complete" CANNOT mix with navigate-knowledge actions')
175
+ expect(request.systemPrompt).not.toContain('"complete" CANNOT mix with tool-call or navigate-knowledge')
176
+ })
177
+
178
+ it('should tell agents not to complete after navigate-knowledge', () => {
179
+ const context = createMockContext({
180
+ config: {
181
+ type: 'agent',
182
+ kind: 'utility',
183
+ resourceId: 'test-agent',
184
+ name: 'Test Agent',
185
+ description: 'Test agent',
186
+ version: '1.0.0',
187
+ status: 'dev',
188
+ systemPrompt: 'You are a test agent',
189
+ sessionCapable: true
190
+ },
191
+ knowledgeMap: {
192
+ nodes: {
193
+ support: {
194
+ id: 'support',
195
+ description: 'Support knowledge',
196
+ loaded: false,
197
+ load: async () => ({ prompt: 'Support prompt' })
198
+ }
199
+ }
200
+ }
201
+ } as Partial<IterationContext>)
202
+ const request = buildReasoningRequest(context)
203
+
204
+ expect(request.includeNavigateKnowledge).toBe(true)
205
+ expect(request.systemPrompt).toContain('"complete" cannot mix with navigate-knowledge')
206
+ expect(request.systemPrompt).toContain('You used navigate-knowledge and need the newly loaded knowledge in the next iteration')
207
+
208
+ const navigateExampleStart = request.systemPrompt.indexOf('**Iteration 1 - Navigate to load knowledge:**')
209
+ const nextIterationStart = request.systemPrompt.indexOf('**Iteration 2 - Use newly available tools:**')
210
+ const navigateExample = request.systemPrompt.slice(navigateExampleStart, nextIterationStart)
211
+
212
+ expect(navigateExampleStart).toBeGreaterThan(-1)
213
+ expect(nextIterationStart).toBeGreaterThan(navigateExampleStart)
214
+ expect(navigateExample).toContain('"type": "navigate-knowledge"')
215
+ expect(navigateExample).not.toContain('"complete"')
216
+ })
217
+ })
218
+
219
+ describe('security prompt', () => {
137
220
  it('should include standard security rules by default', () => {
138
221
  const context = createMockContext()
139
222
  const request = buildReasoningRequest(context)
@@ -68,25 +68,28 @@ ${actionsList}
68
68
 
69
69
  ## Rules
70
70
 
71
- - Batch independent tool calls in one iteration (faster execution)
72
- - Dependent operations need separate iterations (tool B needs tool A's result)
73
- - "complete" cannot mix with tool-call${includeNavigateKnowledge ? '/navigate-knowledge' : ''}${
74
- includeMessageAction
75
- ? `
76
- - Always send at least one message before completing
77
- - When you have your answer, send message + complete in the SAME iteration. Never send a message alone then complete in a later iteration.
78
- - Never repeat or rephrase the same answer across iterations. One clear answer, then complete.`
79
- : ''
80
- }
71
+ - Batch independent tool calls in one iteration (faster execution)
72
+ - Dependent operations need separate iterations (tool B needs tool A's result)
73
+ - "complete" cannot mix with navigate-knowledge${includeNavigateKnowledge ? '' : ' (when available)'}
74
+ - "complete" can mix with tool-call when the tool is a fire-and-forget side effect and you do not need its result before ending${
75
+ includeMessageAction
76
+ ? `
77
+ - Always send at least one message before completing
78
+ - Send at most one message per iteration. Multiple messages in a session turn are collapsed into one visible assistant message.
79
+ - When you have your answer, send message + complete in the SAME iteration. Never send a message alone then complete in a later iteration.
80
+ - Never repeat or rephrase the same answer across iterations. One clear answer, then complete.`
81
+ : ''
82
+ }
81
83
 
82
84
  **Use "complete" when:**
83
85
  - Task finished successfully
84
86
  - Tool returned empty/error results (inform user first)
85
87
  - You need user input to proceed (ask question first)
86
88
 
87
- **Don't use "complete" when:**
88
- - You just called a tool and need its results
89
- - More iterations are needed
89
+ **Don't use "complete" when:**
90
+ - You just called a tool and need its results
91
+ - You used navigate-knowledge and need the newly loaded knowledge in the next iteration
92
+ - More iterations are needed
90
93
 
91
94
  ## Examples
92
95
 
@@ -33,12 +33,13 @@ export function buildToolsPrompt(tools: ToolDefinition[]): string {
33
33
  section +=
34
34
  '{\n "type": "tool-call",\n "id": "unique-id",\n "name": "tool-name",\n "input": { /* tool input matching schema */ }\n}\n\n'
35
35
 
36
- section += '**IMPORTANT RULES:**\n'
37
- section += '1. "complete" CANNOT mix with tool-call or navigate-knowledge actions in the same response\n'
38
- section += '2. "complete" CAN mix with message always pair your final message with complete in the same iteration\n'
39
- section += '3. To use tools, return ONLY tool-call actions, then wait for results in the next iteration\n'
40
- section += '4. After receiving tool results, you can either call more tools OR complete with final answer\n'
41
- section += '5. navigate-knowledge actions load new capabilities - tools become available in the next iteration\n'
36
+ section += '**IMPORTANT RULES:**\n'
37
+ section += '1. "complete" CANNOT mix with navigate-knowledge actions in the same response\n'
38
+ section += '2. "complete" CAN mix with message - always pair your final message with complete in the same iteration\n'
39
+ section += '3. "complete" CAN mix with fire-and-forget tool-call actions when you do not need their results\n'
40
+ section += '4. To use tools and inspect their results, return ONLY tool-call actions, then wait for results in the next iteration\n'
41
+ section += '5. After receiving tool results, you can either call more tools OR complete with final answer\n'
42
+ section += '6. navigate-knowledge actions load new capabilities - tools become available in the next iteration\n'
42
43
 
43
44
  return section + '\n'
44
45
  }
@@ -14,11 +14,12 @@ import type { ToolingErrorType } from './types'
14
14
  * Token usage metadata optionally attached to a ToolCallResponse.
15
15
  * Populated for LLM tool calls where cost accounting is available.
16
16
  */
17
- export interface TokenUsage {
18
- inputTokens: number
19
- outputTokens: number
20
- cost?: number
21
- }
17
+ export interface TokenUsage {
18
+ inputTokens: number
19
+ outputTokens: number
20
+ cost?: number
21
+ model?: string
22
+ }
22
23
 
23
24
  /** Outbound tool-call from worker to parent */
24
25
  export interface ToolCallMessage {