-
Notifications
You must be signed in to change notification settings - Fork 455
feat: allow user message when turn is running #778
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
99712e4
f020d50
a305ca2
8146a69
be08848
2dcf006
8d1ed0d
913ff2a
09bba08
8f27402
ebdd489
4963fd6
564171d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@truefoundry/trueforge-sdk": patch | ||
| --- | ||
|
|
||
| Regenerate SDK from updated OpenAPI spec. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@truefoundry/trueforge-core": patch | ||
| --- | ||
|
|
||
| Allow a user message to start a turn while approvals, client-side tools, or sub-agent threads are pending: close those calls synthetically and cancel open sub-agents with `thread.done` status `cancelled`. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -94,7 +94,7 @@ import { | |
| } from './contextUtils'; | ||
| import { DeferredTool } from './DeferredTool'; | ||
| import { createEmptyAgentThreadMetrics, updateMetricsFromUsage, type AgentThreadMetrics } from './metrics'; | ||
| import { getClosableOpenToolCallIds, OpenToolCallCloser } from './OpenToolCallCloser'; | ||
| import { OpenToolCallCloser } from './OpenToolCallCloser'; | ||
| import { isEmptyMessageContent, processAgentUserInput, type AgentInputUserMessage } from './UserInputMessage'; | ||
|
|
||
| const DEFAULT_ITERATION_LIMIT = 25; | ||
|
|
@@ -177,19 +177,6 @@ function lastAssistantInContext(context: ContextMessage[]): InternalEnrichedAssi | |
| ); | ||
| } | ||
|
|
||
| // Open tool calls that block a new user message: the open set minus those OpenToolCallCloser will | ||
| // auto-close during preSend. Lets a user message resume a thread whose only open calls are dangling | ||
| // regular tool calls (which the closer repairs), while still blocking on approval/client-side/ | ||
| // sub-agent calls that genuinely need resolution. Takes the already-computed open set; copies it | ||
| // since it mutates (deletes) the closable ids. | ||
| function getUnclosableOpenToolCallIds(context: ContextMessage[], openToolCallIds: Set<string>): Set<string> { | ||
| const blockingOpenToolCallIds = new Set(openToolCallIds); | ||
| for (const id of getClosableOpenToolCallIds(context)) { | ||
| blockingOpenToolCallIds.delete(id); | ||
| } | ||
| return blockingOpenToolCallIds; | ||
| } | ||
|
|
||
| function buildMCPInitializeEvent(initInfo: MCPServerInitInfo[], threadId: string): MCPInitializeEvent { | ||
| return { | ||
| type: EventType.MCP_INITIALIZE, | ||
|
|
@@ -260,17 +247,10 @@ function buildModelMessageEvent({ | |
| return event; | ||
| } | ||
|
|
||
| function validateUserMessage( | ||
| message: { content: AgentInputUserMessage['content'] }, | ||
| blockingOpenToolCallIds: Set<string>, | ||
| index: number, | ||
| ): void { | ||
| function validateUserMessage(message: { content: AgentInputUserMessage['content'] }, index: number): void { | ||
| if (isEmptyMessageContent(message.content)) { | ||
| throw new InvalidAgentSendInputError(`messages[${String(index)}] user message has empty content`); | ||
| } | ||
| if (blockingOpenToolCallIds.size > 0) { | ||
| throw new InvalidAgentSendInputError('user message cannot be sent while approvals or questions are pending'); | ||
| } | ||
| } | ||
|
|
||
| function validateToolMessage( | ||
|
|
@@ -318,9 +298,6 @@ function validateInputMessageTypesGivenContext( | |
| ): void { | ||
| // Full open set: validates incoming tool responses and dedupes within the batch. | ||
| const openToolCallIds = getOpenToolCallIds(context); | ||
| // Subset that blocks a fresh user message: excludes calls OpenToolCallCloser will auto-close | ||
|
heerambavi1998 marked this conversation as resolved.
|
||
| // during preSend, so a dangling regular tool call doesn't reject a user message it will repair. | ||
| const blockingOpenToolCallIds = getUnclosableOpenToolCallIds(context, openToolCallIds); | ||
| const pendingApprovalIds = new Set(getPendingApprovalToolCalls(context).map(tc => tc.id)); | ||
| const pendingClientSideIds = new Set(getPendingClientSideToolCalls(context).map(tc => tc.id)); | ||
|
|
||
|
|
@@ -333,11 +310,10 @@ function validateInputMessageTypesGivenContext( | |
| validateApprovalMessage(m, pendingApprovalIds, i); | ||
| pendingApprovalIds.delete(m.tool_call_id); | ||
| } else if (isInputUserMessage(m)) { | ||
| validateUserMessage(m, blockingOpenToolCallIds, i); | ||
| validateUserMessage(m, i); | ||
| } else if (isClientSideToolResponseMessage(m) || isLLMToolMessage(m)) { | ||
| validateToolMessage(m, openToolCallIds, i); | ||
| openToolCallIds.delete(m.tool_call_id); | ||
| blockingOpenToolCallIds.delete(m.tool_call_id); | ||
| pendingClientSideIds.delete(m.tool_call_id); | ||
| } else { | ||
| const _exhaustive: never = m; | ||
|
|
@@ -347,8 +323,11 @@ function validateInputMessageTypesGivenContext( | |
| } | ||
| } | ||
|
|
||
| // A send for a thread awaiting user input must resolve every pending approval and client-side | ||
| // tool call in the same batch; any left unresolved (including an empty batch) is a blocker. | ||
| // User messages interrupt pending work (OpenToolCallCloser synthesizes responses). | ||
| if (messages.some(isInputUserMessage)) { | ||
| return; | ||
| } | ||
|
|
||
| if (pendingApprovalIds.size > 0 || pendingClientSideIds.size > 0) { | ||
| const missing = [...pendingApprovalIds, ...pendingClientSideIds]; | ||
| throw new InvalidAgentSendInputError( | ||
|
|
@@ -503,6 +482,7 @@ export class AgentThread { | |
| private deferredTool?: DeferredTool | undefined; | ||
| private convertedTools: ConvertToolsResult | undefined; | ||
| private pendingSandboxCreatedEvents: SandboxCreatedEvent[] = []; | ||
| private pendingPreSendOutputEvents: ToolResponseEvent[] = []; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can you explain what this is for
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. createTurn only drains send() for context (collectContextAppends ignores output). If we yielded those events from send(), they would never be persisted.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. but do we need to persist?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suppose client already received HITL required actions, on continuing turn we simply close them on BE without yielding and persisting tool closure events, how will client know this was closed synthetically? |
||
| private sandbox?: Sandbox | undefined; | ||
| private readonly tracing: AgentTracing; | ||
| private readonly logger: Logger; | ||
|
|
@@ -599,8 +579,17 @@ export class AgentThread { | |
|
|
||
| this.contextBusy = true; | ||
| try { | ||
| for await (const event of this.executeContextProcessors('preSend')) { | ||
| yield event; | ||
| for await (const event of this.executeContextProcessors('preSend', { | ||
| userMessageIncoming: messages.some(isInputUserMessage), | ||
| })) { | ||
| // createTurn drains send() for context only; surface closer tool.response | ||
| // events at execute start so they persist after turn.created. | ||
| for (const item of event.output) { | ||
| if (item.type === EventType.TOOL_RESPONSE) { | ||
| this.pendingPreSendOutputEvents.push(item); | ||
| } | ||
| } | ||
| yield { ...event, output: [] }; | ||
| } | ||
| this.preSendRanThisTurn = true; | ||
|
|
||
|
|
@@ -876,7 +865,10 @@ export class AgentThread { | |
| }; | ||
| } | ||
|
|
||
| private executeContextProcessors(hook: 'preSend'): AsyncGenerator<AgentThreadAppendContext, void, unknown>; | ||
| private executeContextProcessors( | ||
| hook: 'preSend', | ||
| options: { userMessageIncoming: boolean }, | ||
| ): AsyncGenerator<AgentThreadAppendContext, void, unknown>; | ||
| private executeContextProcessors( | ||
| hook: 'preLLM' | 'postToolCall', | ||
| ): AsyncGenerator< | ||
|
|
@@ -889,6 +881,7 @@ export class AgentThread { | |
| >; | ||
| private async *executeContextProcessors( | ||
| hook: 'preSend' | 'preLLM' | 'postToolCall', | ||
| options?: { userMessageIncoming: boolean }, | ||
| ): AsyncGenerator< | ||
| | ThreadOverwriteContextEvent | ||
| | AgentThreadAppendContext | ||
|
|
@@ -902,7 +895,10 @@ export class AgentThread { | |
| ) => AsyncIterable<AgentContextProcessorOutput>)[]; | ||
| switch (hook) { | ||
| case 'preSend': | ||
| processors = this.preSendContextProcessors.map(p => p.processPreSend.bind(p)); | ||
| processors = this.preSendContextProcessors.map( | ||
| p => (execution: Readonly<AgentThreadExecutionContext>) => | ||
| p.processPreSend(execution, { userMessageIncoming: options?.userMessageIncoming === true }), | ||
| ); | ||
| break; | ||
| case 'preLLM': | ||
| processors = this.preLLMContextProcessors.map(p => p.processPreLLM.bind(p)); | ||
|
|
@@ -1316,11 +1312,15 @@ export class AgentThread { | |
| } | ||
|
|
||
| if (!this.preSendRanThisTurn) { | ||
| for await (const event of this.executeContextProcessors('preSend')) { | ||
| for await (const event of this.executeContextProcessors('preSend', { userMessageIncoming: false })) { | ||
| yield event; | ||
| } | ||
| } | ||
| this.preSendRanThisTurn = false; | ||
| for (const event of this.pendingPreSendOutputEvents) { | ||
| yield event; | ||
| } | ||
| this.pendingPreSendOutputEvents = []; | ||
| const { initializationInfo, authRequirementInfo } = await this.tracing.withInitSpan(() => this.init()); | ||
|
|
||
| if (initializationInfo.length > 0) { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.