Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/20260917035455-regenerate-sdk-from-openapi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge-sdk": patch
---

Regenerate SDK from updated OpenAPI spec.
5 changes: 5 additions & 0 deletions .changeset/steer-session-anytime.md
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`.
Comment thread
cursor[bot] marked this conversation as resolved.
19 changes: 19 additions & 0 deletions .github/fern/openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4564,6 +4564,7 @@
"ThreadState": {
"discriminator": {
"mapping": {
"cancelled": "#/components/schemas/ThreadStateCancelled",
"done": "#/components/schemas/ThreadStateDone",
"error": "#/components/schemas/ThreadStateError"
},
Expand All @@ -4575,9 +4576,27 @@
},
{
"$ref": "#/components/schemas/ThreadStateError"
},
{
"$ref": "#/components/schemas/ThreadStateCancelled"
}
]
},
"ThreadStateCancelled": {
"properties": {
"status": {
"description": "Thread was cancelled before completion.",
"enum": [
"cancelled"
],
"type": "string"
}
},
"required": [
"status"
],
"type": "object"
Comment thread
heerambavi1998 marked this conversation as resolved.
},
"ThreadStateDone": {
"properties": {
"output": {
Expand Down
19 changes: 19 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4564,6 +4564,7 @@
"ThreadState": {
"discriminator": {
"mapping": {
"cancelled": "#/components/schemas/ThreadStateCancelled",
"done": "#/components/schemas/ThreadStateDone",
"error": "#/components/schemas/ThreadStateError"
},
Expand All @@ -4575,9 +4576,27 @@
},
{
"$ref": "#/components/schemas/ThreadStateError"
},
{
"$ref": "#/components/schemas/ThreadStateCancelled"
}
]
},
"ThreadStateCancelled": {
"properties": {
"status": {
"description": "Thread was cancelled before completion.",
"enum": [
"cancelled"
],
"type": "string"
}
},
"required": [
"status"
],
"type": "object"
},
"ThreadStateDone": {
"properties": {
"output": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Bound session handle: starts turns via {@link SessionHandle.createTurn}.
*/
import { InvalidAgentSendInputError } from '../core/errors';
import { newEventId } from '../core/events/schema';
import type { AgentDefinition } from '../core/runtime/AgentDefinition';
import { AgentThread } from '../core/runtime/AgentThread';
Expand Down Expand Up @@ -55,7 +56,9 @@ function toSendBatch(input: TurnInputItem[] | undefined): AgentThreadSendBatch {
if (input.every(isInputUserMessage)) {
return input;
}
throw new Error('input must be homogeneous: all user messages, or all approval/tool-response messages');
throw new InvalidAgentSendInputError(
'input must be homogeneous: all user messages, or all approval/tool-response messages',
);
}

function toNewThreadInit(snapshot: AgentThreadSnapshot): NewThreadInit {
Expand Down
4 changes: 3 additions & 1 deletion packages/trueforge-core/src/agent-session/TurnHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ function toThreadDoneEvent(event: InternalThreadDoneEvent): ThreadDoneEvent {
const state =
event.status === 'error'
? { status: 'error' as const, error: event.error, ...(event.output && { output: event.output }) }
: { status: 'done' as const, output: event.output };
: event.status === 'cancelled'
? { status: 'cancelled' as const }
: { status: 'done' as const, output: event.output };
return {
type: HarnessEventType.THREAD_DONE,
id: newEventId(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ export type AgentContextProcessorOutput =
| AgentContextProcessorCapabilityState;

export interface PreSendContextProcessor {
processPreSend(execution: Readonly<AgentThreadExecutionContext>): AsyncIterable<AgentContextProcessorAppendContext>;
processPreSend(
execution: Readonly<AgentThreadExecutionContext>,
options: { userMessageIncoming: boolean },
): AsyncIterable<AgentContextProcessorAppendContext>;
}

// NOTE: Saved in Redis, Saved in AgentThread in memory. Persisted accross Agent Loop.
Expand Down
9 changes: 8 additions & 1 deletion packages/trueforge-core/src/core/events/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,14 @@ export const ThreadStateErrorSchema = z
})
.openapi('ThreadStateError');

export const ThreadStateCancelledSchema = z
.object({
status: z.literal('cancelled').describe('Thread was cancelled before completion.'),
})
.openapi('ThreadStateCancelled');

export const ThreadStateSchema = z
.discriminatedUnion('status', [ThreadStateDoneSchema, ThreadStateErrorSchema])
.discriminatedUnion('status', [ThreadStateDoneSchema, ThreadStateErrorSchema, ThreadStateCancelledSchema])
.openapi('ThreadState');

export const BaseThreadDoneEventSchema = z
Expand Down Expand Up @@ -381,6 +387,7 @@ export type ModelMessageDeltaEvent = z.infer<typeof ModelMessageDeltaEventSchema
export type ToolResponseEvent = z.infer<typeof ToolResponseEventSchema>;
export type ThreadCreatedEvent = z.infer<typeof ThreadCreatedEventSchema>;
export type ThreadStateError = z.infer<typeof ThreadStateErrorSchema>;
export type ThreadStateCancelled = z.infer<typeof ThreadStateCancelledSchema>;
export type ThreadState = z.infer<typeof ThreadStateSchema>;
export type BaseThreadDoneEvent = z.infer<typeof BaseThreadDoneEventSchema>;
export type ThreadDoneEvent = z.infer<typeof ThreadDoneEventSchema>;
Expand Down
68 changes: 34 additions & 34 deletions packages/trueforge-core/src/core/runtime/AgentThread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Comment thread
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));

Expand All @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -503,6 +482,7 @@ export class AgentThread {
private deferredTool?: DeferredTool | undefined;
private convertedTools: ConvertToolsResult | undefined;
private pendingSandboxCreatedEvents: SandboxCreatedEvent[] = [];
private pendingPreSendOutputEvents: ToolResponseEvent[] = [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you explain what this is for

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.
So this:
Copies tool.response items into pendingPreSendOutputEvents.
Yields the append with output: [] so context still lands in the snapshot.
and when we run execute we flush pendingPreSendOutputEvents

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but do we need to persist?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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?
we need this so the session event log and UI to show those calls as closed

private sandbox?: Sandbox | undefined;
private readonly tracing: AgentTracing;
private readonly logger: Logger;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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<
Expand All @@ -889,6 +881,7 @@ export class AgentThread {
>;
private async *executeContextProcessors(
hook: 'preSend' | 'preLLM' | 'postToolCall',
options?: { userMessageIncoming: boolean },
): AsyncGenerator<
| ThreadOverwriteContextEvent
| AgentThreadAppendContext
Expand All @@ -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));
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ export type InternalMCPAuthRequiredEvent = BaseMCPAuthRequiredEvent & {
export type InternalThreadDoneEvent = BaseThreadDoneEvent & {
type: typeof InternalEventType.AGENT_DONE;
send_to_parent: LLMToolMessage | undefined;
} & ({ status: 'done'; output: ModelMessageEvent } | { status: 'error'; error: string; output?: ModelMessageEvent });
} & (
| { status: 'done'; output: ModelMessageEvent }
| { status: 'error'; error: string; output?: ModelMessageEvent }
| { status: 'cancelled' }
);

export type LLMContextMessage = LLMUserMessage | InternalEnrichedAssistantMessage | LLMToolMessage;

Expand Down
Loading