@xynehq/jaf
Version:
Juspay Agent Framework - A purely functional agent framework with immutable state and composable tools
1,137 lines (1,136 loc) • 67.8 kB
JavaScript
import { z } from 'zod';
import { getTextContent, InterruptionStatus, } from './types.js';
import { setToolRuntime } from './tool-runtime.js';
import { buildEffectiveGuardrails, executeInputGuardrailsParallel, executeInputGuardrailsSequential, executeOutputGuardrails } from './guardrails.js';
import { safeConsole } from '../utils/logger.js';
import { DEFAULT_CLARIFICATION_DESCRIPTION } from '../utils/constants.js';
/**
* Create the built-in clarification tool
*/
function createClarificationTool(config) {
const description = config.clarificationDescription || DEFAULT_CLARIFICATION_DESCRIPTION;
return {
schema: {
name: 'request_user_clarification',
description,
parameters: z.object({
question: z.string().describe('The clarifying question to ask the user'),
options: z.array(z.object({
id: z.string().describe('Unique identifier for this option'),
label: z.string().describe('Human-readable label shown to the user')
})).min(2).describe('clear and meaningful options that user can choose from (minimum 2 options)')
})
},
execute: async (args, _context) => {
const trigger = {
_clarification_trigger: true,
question: args.question,
options: args.options
};
return JSON.stringify(trigger);
}
};
}
export async function run(initialState, config) {
try {
config.onEvent?.({
type: 'run_start',
data: {
runId: initialState.runId,
traceId: initialState.traceId,
context: initialState.context,
userId: initialState.context?.userId,
sessionId: initialState.context?.sessionId || initialState.context?.conversationId,
messages: initialState.messages
}
});
let stateWithMemory = initialState;
if (config.memory?.autoStore && config.conversationId) {
safeConsole.log(`[JAF:ENGINE] Loading conversation history for ${config.conversationId}`);
stateWithMemory = await loadConversationHistory(initialState, config);
}
else {
safeConsole.log(`[JAF:ENGINE] Skipping memory load - autoStore: ${config.memory?.autoStore}, conversationId: ${config.conversationId}`);
}
if (config.approvalStorage) {
safeConsole.log(`[JAF:ENGINE] Loading approvals for runId ${stateWithMemory.runId}`);
const { loadApprovalsIntoState } = await import('./state');
stateWithMemory = await loadApprovalsIntoState(stateWithMemory, config);
}
const result = await runInternal(stateWithMemory, config);
if (config.memory?.autoStore && config.conversationId && result.outcome.status === 'completed' && config.memory.storeOnCompletion) {
safeConsole.log(`[JAF:ENGINE] Storing final completed conversation for ${config.conversationId}`);
await storeConversationHistory(result.finalState, config);
}
else if (result.outcome.status === 'interrupted') {
safeConsole.log(`[JAF:ENGINE] Conversation interrupted - storage already handled during interruption`);
}
else {
safeConsole.log(`[JAF:ENGINE] Skipping memory store - status: ${result.outcome.status}, storeOnCompletion: ${config.memory?.storeOnCompletion}`);
}
config.onEvent?.({
type: 'run_end',
data: {
outcome: result.outcome,
finalState: result.finalState,
traceId: initialState.traceId,
runId: initialState.runId
}
});
return result;
}
catch (error) {
const errorResult = {
finalState: initialState,
outcome: {
status: 'error',
error: {
_tag: 'ModelBehaviorError',
detail: error instanceof Error ? error.message : String(error)
}
}
};
config.onEvent?.({
type: 'run_end',
data: {
outcome: errorResult.outcome,
finalState: errorResult.finalState,
traceId: initialState.traceId,
runId: initialState.runId
}
});
return errorResult;
}
}
function createAsyncEventStream() {
const queue = [];
let resolveNext = null;
let done = false;
return {
push(event) {
if (done)
return;
if (resolveNext) {
const r = resolveNext;
resolveNext = null;
r({ value: event, done: false });
}
else {
queue.push(event);
}
},
end() {
if (done)
return;
done = true;
if (resolveNext) {
const r = resolveNext;
resolveNext = null;
r({ value: undefined, done: true });
}
},
iterator: {
[Symbol.asyncIterator]() {
return this;
},
next() {
if (queue.length > 0) {
return Promise.resolve({ value: queue.shift(), done: false });
}
if (done) {
return Promise.resolve({ value: undefined, done: true });
}
return new Promise((resolve) => {
resolveNext = resolve;
});
},
},
};
}
async function runTurnEndHooks(config, payload) {
config.onEvent?.({
type: 'turn_end',
data: { turn: payload.turn, agentName: payload.agentName }
});
if (config.onTurnEnd) {
await config.onTurnEnd({
turn: payload.turn,
agentName: payload.agentName,
state: payload.state,
lastAssistantMessage: payload.lastAssistantMessage
});
}
}
/**
* Stream run events as they happen via an async generator.
* Consumers can iterate events to build live UIs or forward via SSE.
*
* @param initialState - The initial run state
* @param config - Run configuration
* @param streamEventHandler - Optional event handler for the stream consumer to handle/modify events
*/
export async function* runStream(initialState, config, streamEventHandler) {
const stream = createAsyncEventStream();
const onEvent = async (event) => {
// First, let the stream consumer handle it (can modify before events)
let eventResult;
if (streamEventHandler) {
try {
eventResult = await streamEventHandler(event);
}
catch { /* ignore */ }
}
// Then push to stream for observation
try {
stream.push(event);
}
catch { /* ignore */ }
// Also call config.onEvent if provided
try {
const configResult = await config.onEvent?.(event);
// If config.onEvent returns a value and streamEventHandler didn't, use config result
if (configResult !== undefined && eventResult === undefined) {
eventResult = configResult;
}
}
catch { /* ignore */ }
// Return the result (for before events)
return eventResult;
};
const runPromise = run(initialState, { ...config, onEvent });
void runPromise.finally(() => {
stream.end();
});
try {
for await (const event of stream.iterator) {
yield event;
}
}
finally {
await runPromise.catch(() => undefined);
}
}
async function tryResumePendingToolCalls(state, config) {
try {
const messages = state.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === 'assistant' && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
const ids = new Set(msg.tool_calls.map(tc => tc.id));
const executed = new Set();
for (let j = i + 1; j < messages.length; j++) {
const m = messages[j];
if (m.role === 'tool' && m.tool_call_id && ids.has(m.tool_call_id)) {
executed.add(m.tool_call_id);
}
}
const pendingToolCalls = msg.tool_calls.filter(tc => !executed.has(tc.id));
if (pendingToolCalls.length === 0) {
return null; // Nothing to resume
}
const currentAgent = config.agentRegistry.get(state.currentAgentName);
if (!currentAgent) {
return {
finalState: state,
outcome: {
status: 'error',
error: {
_tag: 'AgentNotFound',
agentName: state.currentAgentName,
}
}
};
}
try {
const requests = pendingToolCalls.map(tc => ({
id: tc.id,
name: tc.function.name,
args: tryParseJSON(tc.function.arguments)
}));
config.onEvent?.({ type: 'tool_requests', data: { toolCalls: requests } });
}
catch { /* ignore */ }
const toolResults = await executeToolCalls(pendingToolCalls, currentAgent, state, config);
const interruptions = toolResults
.map(r => r.interruption)
.filter((it) => it !== undefined);
if (interruptions.length > 0) {
const nonInterruptedResults = toolResults.filter(r => !r.interruption);
return {
finalState: {
...state,
messages: [...state.messages, ...nonInterruptedResults.map(r => r.message)],
turnCount: state.turnCount,
},
outcome: {
status: 'interrupted',
interruptions,
},
};
}
config.onEvent?.({
type: 'tool_results_to_llm',
data: { results: toolResults.map(r => r.message) }
});
const nextState = {
...state,
messages: [...state.messages, ...toolResults.map(r => r.message)],
turnCount: state.turnCount,
approvals: state.approvals ?? new Map(),
};
return await runInternal(nextState, config);
}
}
}
catch {
// Ignore resume errors and continue with normal flow
}
return null;
}
async function runInternal(state, config) {
const resumed = await tryResumePendingToolCalls(state, config);
if (resumed)
return resumed;
// Check if we're resuming from a clarification
if (state.clarifications && state.clarifications.size > 0) {
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage?.role === 'tool') {
try {
const content = JSON.parse(getTextContent(lastMessage.content));
if (content.status === InterruptionStatus.AwaitingClarification) {
const clarificationId = content.clarification_id;
const selectedId = state.clarifications.get(clarificationId);
if (selectedId) {
safeConsole.log(`[JAF:ENGINE] Resuming with clarification: ${clarificationId}, selected option: ${selectedId}`);
// Find the selected option to include in the event
const updatedMessages = [...state.messages];
updatedMessages[updatedMessages.length - 1] = {
...lastMessage,
content: JSON.stringify({
status: InterruptionStatus.ClarificationProvided,
message: `User selected option: ${selectedId}`
})
};
config.onEvent?.({
type: 'clarification_provided',
data: {
clarificationId,
selectedId,
selectedOption: { id: selectedId, label: selectedId }
}
});
// Continue execution with updated messages
const stateWithClarification = {
...state,
messages: updatedMessages
};
return runInternal(stateWithClarification, config);
}
}
}
catch (e) {
safeConsole.log(`[JAF:ENGINE] Error checking for clarification resume:`, e);
}
}
}
const maxTurns = config.maxTurns ?? 50;
if (state.turnCount >= maxTurns) {
return {
finalState: state,
outcome: {
status: 'error',
error: {
_tag: 'MaxTurnsExceeded',
turns: state.turnCount
}
}
};
}
const currentAgent = config.agentRegistry.get(state.currentAgentName);
if (!currentAgent) {
return {
finalState: state,
outcome: {
status: 'error',
error: {
_tag: 'AgentNotFound',
agentName: state.currentAgentName
}
}
};
}
const hasAdvancedGuardrails = !!(currentAgent.advancedConfig?.guardrails &&
(currentAgent.advancedConfig.guardrails.inputPrompt ||
currentAgent.advancedConfig.guardrails.outputPrompt ||
currentAgent.advancedConfig.guardrails.requireCitations));
safeConsole.log('[JAF:ENGINE] Debug guardrails setup:', {
agentName: currentAgent.name,
hasAdvancedConfig: !!currentAgent.advancedConfig,
hasAdvancedGuardrails,
initialInputGuardrails: config.initialInputGuardrails?.length || 0,
finalOutputGuardrails: config.finalOutputGuardrails?.length || 0
});
let effectiveInputGuardrails = [];
let effectiveOutputGuardrails = [];
if (hasAdvancedGuardrails) {
const result = await buildEffectiveGuardrails(currentAgent, config);
effectiveInputGuardrails = result.inputGuardrails;
effectiveOutputGuardrails = result.outputGuardrails;
}
else {
effectiveInputGuardrails = [...(config.initialInputGuardrails || [])];
effectiveOutputGuardrails = [...(config.finalOutputGuardrails || [])];
}
const inputGuardrailsToRun = (state.turnCount === 0 && effectiveInputGuardrails.length > 0)
? effectiveInputGuardrails
: [];
safeConsole.log('[JAF:ENGINE] Input guardrails to run:', {
turnCount: state.turnCount,
effectiveInputLength: effectiveInputGuardrails.length,
inputGuardrailsToRunLength: inputGuardrailsToRun.length,
hasAdvancedGuardrails
});
const effectiveTools = [
...(currentAgent.tools || [])
];
if (config.allowClarificationRequests) {
effectiveTools.push(createClarificationTool(config));
}
const effectiveAgent = {
...currentAgent,
tools: effectiveTools
};
safeConsole.log(`[JAF:ENGINE] Using agent: ${effectiveAgent.name}`);
if (effectiveTools) {
safeConsole.log(`[JAF:ENGINE] Available tools:`, effectiveTools.map(t => t.schema.name));
}
config.onEvent?.({
type: 'agent_processing',
data: {
agentName: effectiveAgent.name,
traceId: state.traceId,
runId: state.runId,
turnCount: state.turnCount,
messageCount: state.messages.length,
toolsAvailable: effectiveTools.map(t => ({
name: t.schema.name,
description: t.schema.description
})),
handoffsAvailable: effectiveAgent.handoffs || [],
modelConfig: effectiveAgent.modelConfig,
hasOutputCodec: !!effectiveAgent.outputCodec,
context: state.context,
currentState: {
messages: state.messages.map(m => ({
role: m.role,
contentLength: m.content?.length || 0,
hasToolCalls: !!m.tool_calls?.length
}))
}
}
});
const model = currentAgent.modelConfig?.name ?? config.modelOverride;
if (!model && !config.modelProvider.isAiSdkProvider) {
return {
finalState: state,
outcome: {
status: 'error',
error: {
_tag: 'ModelBehaviorError',
detail: 'No model configured for agent'
}
}
};
}
const turnNumber = state.turnCount + 1;
config.onEvent?.({ type: 'turn_start', data: { turn: turnNumber, agentName: currentAgent.name } });
const llmCallData = {
agentName: effectiveAgent.name,
model: model || 'unknown',
traceId: state.traceId,
runId: state.runId,
messages: state.messages,
tools: effectiveTools.map(tool => ({
name: tool.schema.name,
description: tool.schema.description,
parameters: tool.schema.parameters
})),
modelConfig: {
...effectiveAgent.modelConfig,
modelOverride: config.modelOverride
},
turnCount: state.turnCount,
context: state.context
};
config.onEvent?.({
type: 'llm_call_start',
data: llmCallData
});
let llmResponse;
let streamingUsed = false;
let assistantEventStreamed = false;
if (inputGuardrailsToRun.length > 0 && state.turnCount === 0) {
const firstUserMessage = state.messages.find(m => m.role === 'user');
if (firstUserMessage) {
if (hasAdvancedGuardrails) {
const executionMode = currentAgent.advancedConfig?.guardrails?.executionMode || 'parallel';
if (executionMode === 'sequential') {
const guardrailResult = await executeInputGuardrailsSequential(inputGuardrailsToRun, firstUserMessage, config);
if (!guardrailResult.isValid) {
await runTurnEndHooks(config, {
turn: turnNumber,
agentName: currentAgent.name,
state,
lastAssistantMessage: undefined
});
return {
finalState: state,
outcome: {
status: 'error',
error: {
_tag: 'InputGuardrailTripwire',
reason: guardrailResult.errorMessage
}
}
};
}
safeConsole.log(`✅ All input guardrails passed. Starting LLM call.`);
llmResponse = await config.modelProvider.getCompletion(state, effectiveAgent, config);
}
else {
const guardrailPromise = executeInputGuardrailsParallel(inputGuardrailsToRun, firstUserMessage, config);
const llmPromise = config.modelProvider.getCompletion(state, effectiveAgent, config);
const [guardrailResult, llmResult] = await Promise.all([
guardrailPromise,
llmPromise
]);
llmResponse = llmResult;
if (!guardrailResult.isValid) {
safeConsole.log(`🚨 Input guardrail violation: ${guardrailResult.errorMessage}`);
safeConsole.log(`[JAF:GUARDRAILS] Discarding LLM response due to input guardrail violation`);
await runTurnEndHooks(config, {
turn: turnNumber,
agentName: currentAgent.name,
state,
lastAssistantMessage: undefined
});
return {
finalState: state,
outcome: {
status: 'error',
error: {
_tag: 'InputGuardrailTripwire',
reason: guardrailResult.errorMessage
}
}
};
}
safeConsole.log(`✅ All input guardrails passed. Using LLM response.`);
}
}
else {
safeConsole.log('[JAF:ENGINE] Using LEGACY guardrails path with', inputGuardrailsToRun.length, 'guardrails');
for (const guardrail of inputGuardrailsToRun) {
const result = await guardrail(getTextContent(firstUserMessage.content));
if (!result.isValid) {
const errorMessage = !result.isValid ? result.errorMessage : '';
config.onEvent?.({
type: 'guardrail_violation',
data: { stage: 'input', reason: errorMessage }
});
await runTurnEndHooks(config, {
turn: turnNumber,
agentName: currentAgent.name,
state,
lastAssistantMessage: undefined
});
return {
finalState: state,
outcome: {
status: 'error',
error: {
_tag: 'InputGuardrailTripwire',
reason: errorMessage
}
}
};
}
}
llmResponse = await config.modelProvider.getCompletion(state, effectiveAgent, config);
}
}
else {
if (typeof config.modelProvider.getCompletionStream === 'function') {
try {
streamingUsed = true;
const stream = config.modelProvider.getCompletionStream(state, effectiveAgent, config);
let aggregatedText = '';
const toolCalls = [];
for await (const chunk of stream) {
if (chunk?.delta) {
aggregatedText += chunk.delta;
}
if (chunk?.toolCallDelta) {
const idx = chunk.toolCallDelta.index ?? 0;
while (toolCalls.length <= idx) {
toolCalls.push({ id: undefined, type: 'function', function: { name: undefined, arguments: '' } });
}
const target = toolCalls[idx];
if (chunk.toolCallDelta.id)
target.id = chunk.toolCallDelta.id;
if (chunk.toolCallDelta.function?.name)
target.function.name = chunk.toolCallDelta.function.name;
if (chunk.toolCallDelta.function?.argumentsDelta) {
target.function.arguments += chunk.toolCallDelta.function.argumentsDelta;
}
}
if (chunk?.delta || chunk?.toolCallDelta) {
assistantEventStreamed = true;
const partialMessage = {
role: 'assistant',
content: aggregatedText,
...(toolCalls.length > 0
? {
tool_calls: toolCalls.map((tc, i) => ({
id: tc.id ?? `call_${i}`,
type: 'function',
function: {
name: tc.function.name ?? '',
arguments: tc.function.arguments
}
}))
}
: {})
};
try {
config.onEvent?.({ type: 'assistant_message', data: { message: partialMessage } });
}
catch (err) {
safeConsole.error('Error in config.onEvent:', err);
}
}
}
llmResponse = {
message: {
content: aggregatedText || undefined,
...(toolCalls.length > 0
? {
tool_calls: toolCalls.map((tc, i) => ({
id: tc.id ?? `call_${i}`,
type: 'function',
function: {
name: tc.function.name ?? '',
arguments: tc.function.arguments
}
}))
}
: {})
}
};
}
catch (e) {
streamingUsed = false;
assistantEventStreamed = false;
llmResponse = await config.modelProvider.getCompletion(state, effectiveAgent, config);
}
}
else {
llmResponse = await config.modelProvider.getCompletion(state, effectiveAgent, config);
}
}
}
else {
if (typeof config.modelProvider.getCompletionStream === 'function') {
try {
streamingUsed = true;
const stream = config.modelProvider.getCompletionStream(state, effectiveAgent, config);
let aggregatedText = '';
const toolCalls = [];
for await (const chunk of stream) {
if (chunk?.delta) {
aggregatedText += chunk.delta;
}
if (chunk?.toolCallDelta) {
const idx = chunk.toolCallDelta.index ?? 0;
while (toolCalls.length <= idx) {
toolCalls.push({ id: undefined, type: 'function', function: { name: undefined, arguments: '' } });
}
const target = toolCalls[idx];
if (chunk.toolCallDelta.id)
target.id = chunk.toolCallDelta.id;
if (chunk.toolCallDelta.function?.name)
target.function.name = chunk.toolCallDelta.function.name;
if (chunk.toolCallDelta.function?.argumentsDelta) {
target.function.arguments += chunk.toolCallDelta.function.argumentsDelta;
}
}
if (chunk?.delta || chunk?.toolCallDelta) {
assistantEventStreamed = true;
const partialMessage = {
role: 'assistant',
content: aggregatedText,
...(toolCalls.length > 0
? {
tool_calls: toolCalls.map((tc, i) => ({
id: tc.id ?? `call_${i}`,
type: 'function',
function: {
name: tc.function.name ?? '',
arguments: tc.function.arguments
}
}))
}
: {})
};
try {
config.onEvent?.({ type: 'assistant_message', data: { message: partialMessage } });
}
catch (err) {
safeConsole.error('Error in config.onEvent:', err);
}
}
}
llmResponse = {
message: {
content: aggregatedText || undefined,
...(toolCalls.length > 0
? {
tool_calls: toolCalls.map((tc, i) => ({
id: tc.id ?? `call_${i}`,
type: 'function',
function: {
name: tc.function.name ?? '',
arguments: tc.function.arguments
}
}))
}
: {})
}
};
}
catch (e) {
streamingUsed = false;
assistantEventStreamed = false;
llmResponse = await config.modelProvider.getCompletion(state, effectiveAgent, config);
}
}
else {
llmResponse = await config.modelProvider.getCompletion(state, effectiveAgent, config);
}
}
const usage = llmResponse?.usage;
const prompt = llmResponse?.prompt;
config.onEvent?.({
type: 'llm_call_end',
data: {
choice: llmResponse,
fullResponse: llmResponse, // Include complete response
prompt: prompt, // Include the prompt that was sent
traceId: state.traceId,
runId: state.runId,
agentName: currentAgent.name,
model: model || 'unknown',
usage: usage ? {
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
total_tokens: usage.total_tokens
} : undefined
}
});
try {
const usage = llmResponse?.usage;
if (usage && (usage.prompt_tokens || usage.completion_tokens || usage.total_tokens)) {
config.onEvent?.({
type: 'token_usage',
data: {
prompt: usage.prompt_tokens,
completion: usage.completion_tokens,
total: usage.total_tokens,
model: model || 'unknown'
}
});
}
}
catch { /* ignore */ }
if (!llmResponse.message) {
await runTurnEndHooks(config, {
turn: turnNumber,
agentName: currentAgent.name,
state,
lastAssistantMessage: undefined
});
return {
finalState: state,
outcome: {
status: 'error',
error: {
_tag: 'ModelBehaviorError',
detail: 'No message in model response'
}
}
};
}
const assistantMessage = {
role: 'assistant',
content: llmResponse.message.content || '',
tool_calls: llmResponse.message.tool_calls
};
if (!assistantEventStreamed) {
config.onEvent?.({
type: 'assistant_message',
data: { message: assistantMessage }
});
}
const newMessages = [...state.messages, assistantMessage];
const updatedTurnCount = state.turnCount + 1;
if (llmResponse.message.tool_calls && llmResponse.message.tool_calls.length > 0) {
safeConsole.log(`[JAF:ENGINE] Processing ${llmResponse.message.tool_calls.length} tool calls`);
safeConsole.log(`[JAF:ENGINE] Tool calls:`, llmResponse.message.tool_calls);
try {
const requests = llmResponse.message.tool_calls.map((tc) => ({
id: tc.id,
name: tc.function.name,
args: tryParseJSON(tc.function.arguments)
}));
config.onEvent?.({ type: 'tool_requests', data: { toolCalls: requests } });
}
catch { /* ignore */ }
const toolResults = await executeToolCalls(llmResponse.message.tool_calls, effectiveAgent, state, config);
const interruptions = toolResults
.map(r => r.interruption)
.filter((interruption) => interruption !== undefined);
if (interruptions.length > 0) {
const completedToolResults = toolResults.filter(r => !r.interruption);
const approvalRequiredResults = toolResults.filter(r => r.interruption);
const updatedApprovals = new Map(state.approvals ?? []);
const updatedClarifications = new Map(state.clarifications ?? []);
for (const interruption of interruptions) {
if (interruption.type === 'tool_approval') {
updatedApprovals.set(interruption.toolCall.id, {
status: 'pending',
approved: false,
additionalContext: { status: 'pending', timestamp: new Date().toISOString() }
});
}
else if (interruption.type === 'clarification_required') {
// Emit clarification requested event
config.onEvent?.({
type: 'clarification_requested',
data: {
clarificationId: interruption.clarificationId,
question: interruption.question,
options: interruption.options,
context: interruption.context
}
});
safeConsole.log(`[JAF:ENGINE] Clarification requested: ${interruption.question}`);
}
}
const interruptedState = {
...state,
messages: [...newMessages, ...completedToolResults.map(r => r.message)],
turnCount: updatedTurnCount,
approvals: updatedApprovals,
clarifications: updatedClarifications,
};
if (config.memory?.autoStore && config.conversationId) {
safeConsole.log(`[JAF:ENGINE] Storing conversation state due to interruption for ${config.conversationId}`);
const stateForStorage = {
...interruptedState,
messages: [...interruptedState.messages, ...approvalRequiredResults.map(r => r.message)]
};
await storeConversationHistory(stateForStorage, config);
}
await runTurnEndHooks(config, {
turn: turnNumber,
agentName: currentAgent.name,
state: interruptedState,
lastAssistantMessage: assistantMessage
});
return {
finalState: interruptedState,
outcome: {
status: 'interrupted',
interruptions,
},
};
}
// safeConsole.log(`[JAF:ENGINE] Tool execution completed. Results count:`, toolResults.length);
config.onEvent?.({
type: 'tool_results_to_llm',
data: { results: toolResults.map(r => r.message) }
});
if (toolResults.some(r => r.isHandoff)) {
const handoffResult = toolResults.find(r => r.isHandoff);
if (handoffResult) {
const targetAgent = handoffResult.targetAgent;
if (!currentAgent.handoffs?.includes(targetAgent)) {
config.onEvent?.({
type: 'handoff_denied',
data: { from: currentAgent.name, to: targetAgent, reason: `Agent ${currentAgent.name} cannot handoff to ${targetAgent}` }
});
const failureState = { ...state, messages: newMessages, turnCount: updatedTurnCount };
await runTurnEndHooks(config, {
turn: turnNumber,
agentName: currentAgent.name,
state: failureState,
lastAssistantMessage: assistantMessage
});
return {
finalState: failureState,
outcome: {
status: 'error',
error: {
_tag: 'HandoffError',
detail: `Agent ${currentAgent.name} cannot handoff to ${targetAgent}`
}
}
};
}
config.onEvent?.({
type: 'handoff',
data: { from: currentAgent.name, to: targetAgent }
});
// Remove any halted messages that are being replaced by actual execution results
const cleanedNewMessages = newMessages.filter(msg => {
if (msg.role !== 'tool')
return true;
try {
const content = JSON.parse(getTextContent(msg.content));
if (content.status === 'halted') {
// Remove this halted message if we have a new result for the same tool_call_id
return !toolResults.some(result => result.message.tool_call_id === msg.tool_call_id);
}
return true;
}
catch {
return true;
}
});
const nextState = {
...state,
messages: [...cleanedNewMessages, ...toolResults.map(r => r.message)],
currentAgentName: targetAgent,
turnCount: updatedTurnCount,
approvals: state.approvals ?? new Map(),
};
await runTurnEndHooks(config, {
turn: turnNumber,
agentName: currentAgent.name,
state: nextState,
lastAssistantMessage: assistantMessage
});
return runInternal(nextState, config);
}
}
// Remove any halted messages that are being replaced by actual execution results
const cleanedNewMessages = newMessages.filter(msg => {
if (msg.role !== 'tool')
return true;
try {
const content = JSON.parse(getTextContent(msg.content));
if (content.status === 'halted') {
// Remove this halted message if we have a new result for the same tool_call_id
return !toolResults.some(result => result.message.tool_call_id === msg.tool_call_id);
}
return true;
}
catch {
return true;
}
});
const nextState = {
...state,
messages: [...cleanedNewMessages, ...toolResults.map(r => r.message)],
turnCount: updatedTurnCount,
approvals: state.approvals ?? new Map(),
};
await runTurnEndHooks(config, {
turn: turnNumber,
agentName: currentAgent.name,
state: nextState,
lastAssistantMessage: assistantMessage
});
return runInternal(nextState, config);
}
if (llmResponse.message.content) {
if (currentAgent.outputCodec) {
const parseResult = currentAgent.outputCodec.safeParse(tryParseJSON(llmResponse.message.content));
if (!parseResult.success) {
config.onEvent?.({ type: 'decode_error', data: { errors: parseResult.error.issues } });
await runTurnEndHooks(config, {
turn: turnNumber,
agentName: currentAgent.name,
state: { ...state, messages: newMessages, turnCount: updatedTurnCount },
lastAssistantMessage: assistantMessage
});
return {
finalState: { ...state, messages: newMessages, turnCount: updatedTurnCount },
outcome: {
status: 'error',
error: {
_tag: 'DecodeError',
errors: parseResult.error.issues
}
}
};
}
let outputGuardrailResult;
if (hasAdvancedGuardrails) {
// Use new advanced system
outputGuardrailResult = await executeOutputGuardrails(effectiveOutputGuardrails, parseResult.data, config);
}
else {
outputGuardrailResult = { isValid: true };
if (effectiveOutputGuardrails && effectiveOutputGuardrails.length > 0) {
for (const guardrail of effectiveOutputGuardrails) {
const result = await guardrail(parseResult.data);
if (!result.isValid) {
const errorMessage = 'errorMessage' in result ? result.errorMessage : 'Guardrail violation';
config.onEvent?.({ type: 'guardrail_violation', data: { stage: 'output', reason: errorMessage } });
outputGuardrailResult = { isValid: false, errorMessage };
break;
}
}
}
}
if (!outputGuardrailResult.isValid) {
await runTurnEndHooks(config, {
turn: turnNumber,
agentName: currentAgent.name,
state: { ...state, messages: newMessages, turnCount: updatedTurnCount },
lastAssistantMessage: assistantMessage
});
return {
finalState: { ...state, messages: newMessages, turnCount: updatedTurnCount },
outcome: {
status: 'error',
error: {
_tag: 'OutputGuardrailTripwire',
reason: outputGuardrailResult.errorMessage || 'Output guardrail violation'
}
}
};
}
config.onEvent?.({ type: 'final_output', data: { output: parseResult.data } });
// End of turn
await runTurnEndHooks(config, {
turn: turnNumber,
agentName: currentAgent.name,
state: { ...state, messages: newMessages, turnCount: updatedTurnCount },
lastAssistantMessage: assistantMessage
});
return {
finalState: { ...state, messages: newMessages, turnCount: updatedTurnCount },
outcome: {
status: 'completed',
output: parseResult.data
}
};
}
else {
let outputGuardrailResult;
if (hasAdvancedGuardrails) {
// Use new advanced system
outputGuardrailResult = await executeOutputGuardrails(effectiveOutputGuardrails, llmResponse.message.content, config);
}
else {
outputGuardrailResult = { isValid: true };
if (effectiveOutputGuardrails && effectiveOutputGuardrails.length > 0) {
for (const guardrail of effectiveOutputGuardrails) {
const result = await guardrail(llmResponse.message.content);
if (!result.isValid) {
const errorMessage = 'errorMessage' in result ? result.errorMessage : 'Guardrail violation';
config.onEvent?.({ type: 'guardrail_violation', data: { stage: 'output', reason: errorMessage } });
outputGuardrailResult = { isValid: false, errorMessage };
break;
}
}
}
}
if (!outputGuardrailResult.isValid) {
await runTurnEndHooks(config, {
turn: turnNumber,
agentName: currentAgent.name,
state: { ...state, messages: newMessages, turnCount: updatedTurnCount },
lastAssistantMessage: assistantMessage
});
return {
finalState: { ...state, messages: newMessages, turnCount: updatedTurnCount },
outcome: {
status: 'error',
error: {
_tag: 'OutputGuardrailTripwire',
reason: outputGuardrailResult.errorMessage || 'Output guardrail violation'
}
}
};
}
config.onEvent?.({ type: 'final_output', data: { output: llmResponse.message.content } });
// End of turn
await runTurnEndHooks(config, {
turn: turnNumber,
agentName: currentAgent.name,
state: { ...state, messages: newMessages, turnCount: updatedTurnCount },
lastAssistantMessage: assistantMessage
});
return {
finalState: { ...state, messages: newMessages, turnCount: updatedTurnCount },
outcome: {
status: 'completed',
output: llmResponse.message.content
}
};
}
}
await runTurnEndHooks(config, {
turn: turnNumber,
agentName: currentAgent.name,
state: { ...state, messages: newMessages, turnCount: updatedTurnCount },
lastAssistantMessage: assistantMessage
});
safeConsole.error(`[JAF:ENGINE] No tool calls or content returned by model. LLMResponse: `, llmResponse);
return {
finalState: { ...state, messages: newMessages, turnCount: updatedTurnCount },
outcome: {
status: 'error',
error: {
_tag: 'ModelBehaviorError',
detail: 'Model produced neither content nor tool calls'
}
}
};
}
async function executeToolCalls(toolCalls, agent, state, config) {
try {
setToolRuntime(state.context, { state, config });
}
catch { /* ignore */ }
const results = await Promise.all(toolCalls.map(async (toolCall) => {
const tool = agent.tools?.find(t => t.schema.name === toolCall.function.name);
const startTime = Date.now();
let rawArgs = tryParseJSON(toolCall.function.arguments);
// Emit before_tool_execution event - handler can return modified args
if (config.onEvent) {
try {
const beforeEventResponse = await config.onEvent({
type: 'before_tool_execution',
data: {
toolName: toolCall.function.name,
args: rawArgs,
toolCall,
traceId: state.traceId,
runId: state.runId,
toolSchema: tool ? {
name: tool.schema.name,
description: tool.schema.description,
parameters: tool.schema.parameters
} : undefined,
context: state.context,
state,
agentName: agent.name
}
});
// If event handler returns a value, use it to override the args
if (beforeEventResponse !== undefined && beforeEventResponse !== null) {
rawArgs = beforeEventResponse;