Skip to content

Commit 12dc8d7

Browse files
authored
feat: add text based tool updates for agentic-chat (#984)
* feat: add bash tools to agentic-chat * refactor: move tool processing out of main function * feat: add tool results log after running * feat: add responseStream obj to abstract streaming to client * feat: add simple markdown ui * refactor: clean up implementation of response stream and add tests * refactor: clean up tests * refactor: remove unused intermediate results * test: update controller tests to ignore tool based updates * refactor: rename response to chatresultstream * refactor: clean up some loose ends * fix: avoid exposing executeBash in current state * docs: add comment for future steps * docs: add note about formatting
1 parent f5dac38 commit 12dc8d7

File tree

5 files changed

+222
-27
lines changed

5 files changed

+222
-27
lines changed

server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ describe('AgenticChatController', () => {
439439
assert.ok(secondCallArgs.conversationState?.history[1].assistantResponseMessage)
440440

441441
// Verify the final result
442-
assert.deepStrictEqual(chatResult, expectedCompleteChatResult)
442+
assertChatResultsMatch(chatResult, expectedCompleteChatResult)
443443
})
444444

445445
it('propagates tool execution errors to the model in toolResults', async () => {
@@ -574,7 +574,7 @@ describe('AgenticChatController', () => {
574574
}
575575

576576
// Verify the final result includes both messages
577-
assert.deepStrictEqual(chatResult, expectedErrorChatResult)
577+
assertChatResultsMatch(chatResult, expectedErrorChatResult)
578578
})
579579

580580
it('handles multiple iterations of tool uses with proper history updates', async () => {
@@ -764,7 +764,7 @@ describe('AgenticChatController', () => {
764764
)
765765

766766
// Verify the final result
767-
assert.deepStrictEqual(chatResult, expectedCompleteChatResult)
767+
assertChatResultsMatch(chatResult, expectedCompleteChatResult)
768768
})
769769

770770
it('returns help message if it is a help follow up action', async () => {
@@ -1655,3 +1655,16 @@ ${' '.repeat(8)}}
16551655
sinon.assert.calledOnce(tabBarActionStub)
16561656
})
16571657
})
1658+
1659+
// The body may include text-based progress updates from tool invocations.
1660+
// We want to ignore these in the tests.
1661+
function assertChatResultsMatch(actual: any, expected: ChatResult) {
1662+
if (actual?.body && expected?.body) {
1663+
assert.ok(
1664+
actual.body.endsWith(expected.body),
1665+
`Body should end with "${expected.body}"\nActual: "${actual.body}"`
1666+
)
1667+
}
1668+
1669+
assert.deepStrictEqual({ ...actual, body: undefined }, { ...expected, body: undefined })
1670+
}

server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ import {
6969
ChatResultWithMetadata as AgenticChatResultWithMetadata,
7070
} from './agenticChatEventParser'
7171
import { ChatSessionService } from '../chat/chatSessionService'
72+
import { AgenticChatResultStream } from './agenticChatResultStream'
73+
import { executeToolMessage, toolErrorMessage, toolResultMessage } from './textFormatting'
7274

7375
type ChatHandlers = Omit<
7476
LspHandlers<Chat>,
@@ -122,10 +124,21 @@ export class AgenticChatController implements ChatHandlers {
122124
return this.#tabBarController.onConversationClick(params)
123125
}
124126

127+
async #sendProgressToClient(chunk: ChatResult | string, partialResultToken?: string | number) {
128+
if (!isNullish(partialResultToken)) {
129+
await this.#features.lsp.sendProgress(chatRequestType, partialResultToken, chunk)
130+
}
131+
}
132+
133+
#getChatResultStream(partialResultToken?: string | number): AgenticChatResultStream {
134+
return new AgenticChatResultStream(async (chunk: ChatResult | string) =>
135+
this.#sendProgressToClient(chunk, partialResultToken)
136+
)
137+
}
138+
125139
async onChatPrompt(params: ChatParams, token: CancellationToken): Promise<ChatResult | ResponseError<ChatResult>> {
126140
// Phase 1: Initial Setup - This happens only once
127141
const maybeDefaultResponse = getDefaultChatResponse(params.prompt.prompt)
128-
129142
if (maybeDefaultResponse) {
130143
return maybeDefaultResponse
131144
}
@@ -151,7 +164,7 @@ export class AgenticChatController implements ChatHandlers {
151164
})
152165

153166
const conversationIdentifier = session?.conversationId ?? 'New conversation'
154-
167+
const chatResultStream = this.#getChatResultStream(params.partialResultToken)
155168
try {
156169
// Get the initial request input
157170
const initialRequestInput = await this.#prepareRequestInput(params, session, triggerContext)
@@ -161,7 +174,7 @@ export class AgenticChatController implements ChatHandlers {
161174
initialRequestInput,
162175
session,
163176
metric,
164-
params.partialResultToken,
177+
chatResultStream,
165178
conversationIdentifier,
166179
token
167180
)
@@ -173,7 +186,8 @@ export class AgenticChatController implements ChatHandlers {
173186
params,
174187
metric,
175188
triggerContext,
176-
isNewConversation
189+
isNewConversation,
190+
chatResultStream
177191
)
178192
} catch (err) {
179193
return this.#handleRequestError(err, params.tabId, metric)
@@ -214,15 +228,14 @@ export class AgenticChatController implements ChatHandlers {
214228
initialRequestInput: GenerateAssistantResponseCommandInput,
215229
session: ChatSessionService,
216230
metric: Metric<CombinedConversationEvent>,
217-
partialResultToken?: string | number,
231+
chatResultStream: AgenticChatResultStream,
218232
conversationIdentifier?: string,
219233
token?: CancellationToken
220234
): Promise<Result<AgenticChatResultWithMetadata, string>> {
221235
let currentRequestInput = { ...initialRequestInput }
222236
let finalResult: Result<AgenticChatResultWithMetadata, string> | null = null
223237
let iterationCount = 0
224238
const maxIterations = 10 // Safety limit to prevent infinite loops
225-
226239
metric.recordStart()
227240

228241
while (iterationCount < maxIterations) {
@@ -247,7 +260,7 @@ export class AgenticChatController implements ChatHandlers {
247260
cwsprChatResponseCode: response.$metadata.httpStatusCode,
248261
cwsprChatMessageId: response.$metadata.requestId,
249262
}),
250-
partialResultToken
263+
chatResultStream
251264
)
252265

253266
// Store the conversation ID from the first response
@@ -267,7 +280,7 @@ export class AgenticChatController implements ChatHandlers {
267280
const currentMessage = currentRequestInput.conversationState?.currentMessage
268281

269282
// Process tool uses and update the request input for the next iteration
270-
const toolResults = await this.#processToolUses(pendingToolUses)
283+
const toolResults = await this.#processToolUses(pendingToolUses, chatResultStream)
271284
currentRequestInput = this.#updateRequestInputWithToolResults(currentRequestInput, toolResults)
272285

273286
if (!currentRequestInput.conversationState!.history) {
@@ -318,14 +331,17 @@ export class AgenticChatController implements ChatHandlers {
318331
/**
319332
* Processes tool uses by running the tools and collecting results
320333
*/
321-
async #processToolUses(toolUses: Array<ToolUse & { stop: boolean }>): Promise<ToolResult[]> {
334+
async #processToolUses(
335+
toolUses: Array<ToolUse & { stop: boolean }>,
336+
chatResultStream: AgenticChatResultStream
337+
): Promise<ToolResult[]> {
322338
const results: ToolResult[] = []
323339

324340
for (const toolUse of toolUses) {
325341
if (!toolUse.name || !toolUse.toolUseId) continue
326342

327343
try {
328-
this.#debug(`Running tool ${toolUse.name} with input:`, JSON.stringify(toolUse.input))
344+
await chatResultStream.writeResultBlock({ body: `${executeToolMessage(toolUse)}` })
329345

330346
const result = await this.#features.agent.runTool(toolUse.name, toolUse.input)
331347
let toolResultContent: ToolResultContentBlock
@@ -343,10 +359,13 @@ export class AgenticChatController implements ChatHandlers {
343359
status: 'success',
344360
content: [toolResultContent],
345361
})
346-
347-
this.#debug(`Tool ${toolUse.name} completed with result:`, JSON.stringify(result))
362+
await chatResultStream.writeResultBlock({ body: toolResultMessage(toolUse, result) })
348363
} catch (err) {
349-
this.#log(`Error running tool ${toolUse.name}:`, err instanceof Error ? err.message : 'unknown error')
364+
const errMsg = err instanceof Error ? err.message : 'unknown error'
365+
await chatResultStream.writeResultBlock({
366+
body: toolErrorMessage(toolUse, errMsg),
367+
})
368+
this.#log(`Error running tool ${toolUse.name}:`, errMsg)
350369
results.push({
351370
toolUseId: toolUse.toolUseId,
352371
status: 'error',
@@ -394,12 +413,12 @@ export class AgenticChatController implements ChatHandlers {
394413
params: ChatParams,
395414
metric: Metric<CombinedConversationEvent>,
396415
triggerContext: TriggerContext,
397-
isNewConversation: boolean
416+
isNewConversation: boolean,
417+
chatResultStream: AgenticChatResultStream
398418
): Promise<ChatResult | ResponseError<ChatResult>> {
399419
if (!result.success) {
400420
return new ResponseError<ChatResult>(LSPErrorCodes.RequestFailed, result.error)
401421
}
402-
403422
const conversationId = session.conversationId
404423
this.#debug('Final session conversation id:', conversationId || '')
405424

@@ -452,7 +471,7 @@ export class AgenticChatController implements ChatHandlers {
452471
})
453472
}
454473

455-
return result.data.chatResult
474+
return chatResultStream.getResult()
456475
}
457476

458477
/**
@@ -792,11 +811,11 @@ export class AgenticChatController implements ChatHandlers {
792811
async #processGenerateAssistantResponseResponse(
793812
response: GenerateAssistantResponseCommandOutput,
794813
metric: Metric<AddMessageEvent>,
795-
partialResultToken?: string | number
814+
chatResultStream: AgenticChatResultStream
796815
): Promise<Result<AgenticChatResultWithMetadata, string>> {
797816
const requestId = response.$metadata.requestId!
798817
const chatEventParser = new AgenticChatEventParser(requestId, metric)
799-
818+
const streamWriter = chatResultStream.getResultStreamWriter()
800819
for await (const chatEvent of response.generateAssistantResponseResponse!) {
801820
const result = chatEventParser.processPartialEvent(chatEvent)
802821

@@ -805,10 +824,9 @@ export class AgenticChatController implements ChatHandlers {
805824
return result
806825
}
807826

808-
if (!isNullish(partialResultToken)) {
809-
await this.#features.lsp.sendProgress(chatRequestType, partialResultToken, result.data.chatResult)
810-
}
827+
await streamWriter.write(result.data.chatResult)
811828
}
829+
await streamWriter.close()
812830

813831
metric.mergeWith({
814832
cwsprChatFullResponseLatency: metric.getTimeElapsed(),
@@ -837,9 +855,7 @@ export class AgenticChatController implements ChatHandlers {
837855
return result
838856
}
839857

840-
if (!isNullish(partialResultToken)) {
841-
await this.#features.lsp.sendProgress(chatRequestType, partialResultToken, result.data.chatResult)
842-
}
858+
await this.#sendProgressToClient(result.data.chatResult, partialResultToken)
843859
}
844860

845861
return chatEventParser.getResult()
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import * as assert from 'assert'
2+
import { ChatResult } from '@aws/language-server-runtimes/protocol'
3+
import { AgenticChatResultStream } from './agenticChatResultStream'
4+
import { TestFeatures } from '@aws/language-server-runtimes/testing'
5+
6+
describe('agenticChatResponse', function () {
7+
let output: (ChatResult | string)[] = []
8+
const logging = new TestFeatures().logging
9+
const sendProgress = async (s: ChatResult | string) => void output.push(s)
10+
let chatResultStream: AgenticChatResultStream
11+
12+
beforeEach(function () {
13+
output = []
14+
chatResultStream = new AgenticChatResultStream(sendProgress)
15+
})
16+
17+
it('combines all previous results on write', async function () {
18+
await chatResultStream.writeResultBlock({ body: 'first' })
19+
await chatResultStream.writeResultBlock({ body: 'second' })
20+
await chatResultStream.writeResultBlock({ body: 'third' })
21+
22+
assert.deepStrictEqual(chatResultStream.getResult(), {
23+
body: `first${AgenticChatResultStream.resultDelimiter}second${AgenticChatResultStream.resultDelimiter}third`,
24+
})
25+
})
26+
27+
it('inherits properties from the last result', async function () {
28+
await chatResultStream.writeResultBlock({ body: 'first', messageId: '1' })
29+
await chatResultStream.writeResultBlock({ body: 'second', messageId: '2' })
30+
await chatResultStream.writeResultBlock({ body: 'third', messageId: '3' })
31+
32+
assert.deepStrictEqual(chatResultStream.getResult(), {
33+
body: `first${AgenticChatResultStream.resultDelimiter}second${AgenticChatResultStream.resultDelimiter}third`,
34+
messageId: '3',
35+
})
36+
})
37+
38+
it('streams the results to the chat', async function () {
39+
const writer = chatResultStream.getResultStreamWriter()
40+
await writer.write({ body: 'f' })
41+
await writer.write({ body: 'fi' })
42+
await writer.write({ body: 'firs' })
43+
await writer.write({ body: 'first' })
44+
45+
await writer.close()
46+
47+
assert.deepStrictEqual(chatResultStream.getResult(), { body: 'first' })
48+
})
49+
50+
it('combines results properly', async function () {
51+
const writer = chatResultStream.getResultStreamWriter()
52+
await writer.write({ body: 'f', messageId: '1' })
53+
await writer.write({ body: 'fi', messageId: '1' })
54+
await writer.write({ body: 'firs', messageId: '1' })
55+
await writer.write({ body: 'first', messageId: '1' })
56+
57+
await writer.close()
58+
59+
assert.deepStrictEqual(chatResultStream.getResult(), {
60+
messageId: '1',
61+
body: 'first',
62+
})
63+
})
64+
65+
it('throws error if multiple stream writers are initialized', async function () {
66+
const writer = chatResultStream.getResultStreamWriter()
67+
assert.throws(() => chatResultStream.getResultStreamWriter(), Error)
68+
await writer.close()
69+
70+
assert.ok(chatResultStream.getResultStreamWriter())
71+
})
72+
})
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { ChatResult } from '@aws/language-server-runtimes/protocol'
2+
3+
interface ResultStreamWriter {
4+
write(chunk: ChatResult): Promise<void>
5+
close(): Promise<void>
6+
}
7+
8+
/**
9+
* This class wraps around lsp.sendProgress to provide a more helpful interface for streaming a ChatResult to the client.
10+
* ChatResults are grouped into blocks that can be written directly, or streamed in.
11+
* In the final message, blocks are seperated by resultDelimiter defined below.
12+
*/
13+
export class AgenticChatResultStream {
14+
static readonly resultDelimiter = '\n\n'
15+
#state = {
16+
chatResultBlocks: [] as ChatResult[],
17+
isLocked: false,
18+
}
19+
readonly #sendProgress: (newChatResult: ChatResult | string) => Promise<void>
20+
21+
constructor(sendProgress: (newChatResult: ChatResult | string) => Promise<void>) {
22+
this.#sendProgress = sendProgress
23+
}
24+
25+
getResult(): ChatResult {
26+
return this.#joinResults(this.#state.chatResultBlocks)
27+
}
28+
29+
#joinResults(chatResults: ChatResult[]): ChatResult {
30+
// TODO: if we add ui elements to ChatResult in the response, we need to be more aware of how we combine them.
31+
return chatResults.reduceRight((acc, c) => ({
32+
...acc,
33+
body: c.body + AgenticChatResultStream.resultDelimiter + acc.body,
34+
}))
35+
}
36+
37+
async writeResultBlock(result: ChatResult) {
38+
this.#state.chatResultBlocks.push(result)
39+
await this.#sendProgress(this.getResult())
40+
}
41+
42+
getResultStreamWriter(): ResultStreamWriter {
43+
// Note: if write calls are not awaited, stream can be out-of-order.
44+
if (this.#state.isLocked) {
45+
throw new Error('AgenticChatResultStream: already locked')
46+
}
47+
this.#state.isLocked = true
48+
let lastResult: ChatResult | undefined
49+
50+
return {
51+
write: async (intermediateChatResult: ChatResult) => {
52+
const combinedResult = this.#joinResults([...this.#state.chatResultBlocks, intermediateChatResult])
53+
lastResult = intermediateChatResult
54+
return await this.#sendProgress(combinedResult)
55+
},
56+
close: async () => {
57+
if (lastResult) {
58+
this.#state.chatResultBlocks.push(lastResult)
59+
}
60+
this.#state.isLocked = false
61+
},
62+
}
63+
}
64+
}

0 commit comments

Comments
 (0)