Skip to content

Commit c77cd7e

Browse files
jcheekAiden
andcommitted
feat: implement CALL hook command for asynchronous tool execution
Adds CALL hook commands that execute tools asynchronously after hook processing. Features recursion depth limit (5 levels), duplicate prevention, fire-and-forget execution, and CONDITIONAL support. ⚡️ Powered by ZDS AI Co-Authored-By: Aiden <aiden@ai.zds.group>
1 parent c74ee27 commit c77cd7e

3 files changed

Lines changed: 293 additions & 35 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@zds-ai/cli",
3-
"version": "0.1.9-alpha.5",
3+
"version": "0.1.9-alpha.6",
44
"description": "A multi-backend AI agent CLI supporting OpenAI, Anthropic, Grok, Ollama, and more.",
55
"type": "module",
66
"main": "dist/index.js",

src/agent/hook-manager.ts

Lines changed: 187 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ import { logApiError } from "../utils/error-logger.js";
88
import { ChatEntry } from "./llm-agent.js";
99
import { Variable } from "./prompt-variables.js";
1010

11+
/**
12+
* Context for tracking CALL recursion depth and duplicate prevention
13+
*/
14+
interface CallContext {
15+
/** Current recursion depth */
16+
depth: number;
17+
/** Set of already-executed call signatures (toolName + serialized arguments) */
18+
executedCalls: Set<string>;
19+
}
20+
1121
/**
1222
* Dependencies required by HookManager for hook execution and state management
1323
*/
@@ -38,6 +48,8 @@ export interface HookManagerDependencies {
3848
setTokenCounter(counter: TokenCounter): void;
3949
/** Set LLM client */
4050
setLLMClient(client: LLMClient): void;
51+
/** Execute a tool by name with parameters (for CALL commands) */
52+
executeToolByName?(toolName: string, parameters: Record<string, any>): Promise<{ success: boolean; output?: string; error?: string; hookCommands?: any[] }>;
4153
}
4254

4355
/**
@@ -374,6 +386,32 @@ export class HookManager {
374386
const hasBackendChange = commands.backend && commands.baseUrl && commands.apiKeyEnvVar;
375387
const hasModelChange = commands.model;
376388

389+
// Apply immediate (non-conditional) commands right away
390+
applyEnvVariables(commands.env);
391+
for (const {name, value} of commands.promptVars) {
392+
Variable.set(name, value);
393+
}
394+
if (commands.system) {
395+
this.deps.chatHistory.push({
396+
type: "system",
397+
content: commands.system,
398+
timestamp: new Date(),
399+
});
400+
}
401+
402+
// Check for CONDITIONAL commands without any CONDITION - this is an error
403+
if (commands.conditionalResults && !hasBackendChange && !hasModelChange) {
404+
const errorMsg = "Hook error: CONDITIONAL commands present but no CONDITION BACKEND or CONDITION MODEL specified. Conditional commands ignored.";
405+
console.warn(errorMsg);
406+
this.deps.chatHistory.push({
407+
type: "system",
408+
content: errorMsg,
409+
timestamp: new Date(),
410+
});
411+
// Don't return false - allow processing to continue, just skip the conditional commands
412+
}
413+
414+
// If there's a backend/model change, test it and apply conditional commands on success
377415
if (hasBackendChange) {
378416
const testResult = await this.testBackendModelChange(
379417
commands.backend!,
@@ -395,11 +433,19 @@ export class HookManager {
395433
return false;
396434
}
397435

398-
applyEnvVariables(commands.env);
399-
400-
// Apply prompt variables (SET, SET_FILE, SET_TEMP_FILE commands)
401-
for (const {name, value} of commands.promptVars) {
402-
Variable.set(name, value);
436+
// Apply conditional commands after successful test
437+
if (commands.conditionalResults) {
438+
applyEnvVariables(commands.conditionalResults.env);
439+
for (const {name, value} of commands.conditionalResults.promptVars) {
440+
Variable.set(name, value);
441+
}
442+
if (commands.conditionalResults.system) {
443+
this.deps.chatHistory.push({
444+
type: "system",
445+
content: commands.conditionalResults.system,
446+
timestamp: new Date(),
447+
});
448+
}
403449
}
404450

405451
const parts = [];
@@ -430,11 +476,19 @@ export class HookManager {
430476
return false;
431477
}
432478

433-
applyEnvVariables(commands.env);
434-
435-
// Apply prompt variables (SET, SET_FILE, SET_TEMP_FILE commands)
436-
for (const {name, value} of commands.promptVars) {
437-
Variable.set(name, value);
479+
// Apply conditional commands after successful test
480+
if (commands.conditionalResults) {
481+
applyEnvVariables(commands.conditionalResults.env);
482+
for (const {name, value} of commands.conditionalResults.promptVars) {
483+
Variable.set(name, value);
484+
}
485+
if (commands.conditionalResults.system) {
486+
this.deps.chatHistory.push({
487+
type: "system",
488+
content: commands.conditionalResults.system,
489+
timestamp: new Date(),
490+
});
491+
}
438492
}
439493

440494
const successMsg = `Model changed to "${commands.model}"`;
@@ -445,20 +499,21 @@ export class HookManager {
445499
});
446500

447501
this.deps.emit('modelChange', { model: commands.model });
448-
} else {
449-
applyEnvVariables(commands.env);
502+
}
503+
// If no backend/model change, conditional commands are ignored (there's no condition to satisfy)
450504

451-
// Apply prompt variables (SET, SET_FILE, SET_TEMP_FILE commands)
452-
for (const {name, value} of commands.promptVars) {
453-
Variable.set(name, value);
454-
}
505+
// Execute CALL commands after all other processing (fire-and-forget)
506+
// Execute immediate CALLs
507+
if (commands.calls.length > 0) {
508+
this.executeCalls(commands.calls).catch(error => {
509+
console.error("Error executing immediate CALL commands:", error);
510+
});
455511
}
456512

457-
if (commands.system) {
458-
this.deps.chatHistory.push({
459-
type: "system",
460-
content: commands.system,
461-
timestamp: new Date(),
513+
// Execute conditional CALLs only if backend/model test succeeded
514+
if (commands.conditionalResults && commands.conditionalResults.calls.length > 0 && (hasBackendChange || hasModelChange)) {
515+
this.executeCalls(commands.conditionalResults.calls).catch(error => {
516+
console.error("Error executing conditional CALL commands:", error);
462517
});
463518
}
464519

@@ -692,4 +747,115 @@ export class HookManager {
692747
return true;
693748
});
694749
}
750+
751+
/**
752+
* Execute CALL commands asynchronously with recursion depth and duplicate tracking
753+
* Fire-and-forget execution that processes hooks from called tools
754+
*
755+
* @param calls Array of CALL command strings
756+
* @param context Call context for tracking recursion and duplicates
757+
*/
758+
private async executeCalls(calls: string[], context: CallContext = { depth: 0, executedCalls: new Set() }): Promise<void> {
759+
// Maximum recursion depth is 5
760+
const MAX_DEPTH = 5;
761+
762+
if (context.depth >= MAX_DEPTH) {
763+
console.warn(`CALL recursion depth limit (${MAX_DEPTH}) reached, skipping remaining calls`);
764+
return;
765+
}
766+
767+
// Check if executeToolByName is available
768+
if (!this.deps.executeToolByName) {
769+
console.warn("CALL commands require executeToolByName dependency, skipping calls");
770+
return;
771+
}
772+
773+
for (const callSpec of calls) {
774+
// Parse "toolName arg1=val1 arg2=val2"
775+
const parts = callSpec.trim().split(/\s+/);
776+
if (parts.length === 0) {
777+
continue;
778+
}
779+
780+
const toolName = parts[0];
781+
const parameters: Record<string, any> = {};
782+
783+
// Parse parameters
784+
for (let i = 1; i < parts.length; i++) {
785+
const match = parts[i].match(/^([^=]+)=(.*)$/);
786+
if (match) {
787+
const [, key, value] = match;
788+
// Try to parse as JSON, fall back to string
789+
try {
790+
parameters[key] = JSON.parse(value);
791+
} catch {
792+
parameters[key] = value;
793+
}
794+
}
795+
}
796+
797+
// Create signature for duplicate detection
798+
const signature = `${toolName}:${JSON.stringify(parameters)}`;
799+
if (context.executedCalls.has(signature)) {
800+
console.warn(`Skipping duplicate CALL: ${signature}`);
801+
continue;
802+
}
803+
804+
// Mark as executed
805+
context.executedCalls.add(signature);
806+
807+
// Execute tool asynchronously (fire-and-forget)
808+
this.executeCallAsync(toolName, parameters, context).catch(error => {
809+
console.error(`Error executing CALL ${toolName}:`, error);
810+
});
811+
}
812+
}
813+
814+
/**
815+
* Execute a single CALL asynchronously with hook processing
816+
* Runs tool hooks which may generate more CALL commands
817+
*
818+
* @param toolName Tool to execute
819+
* @param parameters Tool parameters
820+
* @param context Call context for tracking recursion
821+
*/
822+
private async executeCallAsync(
823+
toolName: string,
824+
parameters: Record<string, any>,
825+
context: CallContext
826+
): Promise<void> {
827+
if (!this.deps.executeToolByName) {
828+
return;
829+
}
830+
831+
try {
832+
// Execute the tool
833+
const result = await this.deps.executeToolByName(toolName, parameters);
834+
835+
// Process any hook commands that were generated during tool execution
836+
if (result.hookCommands && result.hookCommands.length > 0) {
837+
const hookResults = applyHookCommands(result.hookCommands);
838+
839+
// Extract CALL commands from hook results (both immediate and conditional)
840+
const recursiveCalls: string[] = [...hookResults.calls];
841+
842+
// Add conditional calls if present (they would have been validated by the tool's hooks)
843+
if (hookResults.conditionalResults && hookResults.conditionalResults.calls.length > 0) {
844+
recursiveCalls.push(...hookResults.conditionalResults.calls);
845+
}
846+
847+
// Recursively execute CALL commands with incremented depth
848+
if (recursiveCalls.length > 0) {
849+
const nestedContext: CallContext = {
850+
depth: context.depth + 1,
851+
executedCalls: context.executedCalls, // Share the same set to prevent duplicates across entire chain
852+
};
853+
await this.executeCalls(recursiveCalls, nestedContext);
854+
}
855+
}
856+
857+
} catch (error) {
858+
console.error(`Error in executeCallAsync for ${toolName}:`, error);
859+
}
860+
}
695861
}

0 commit comments

Comments
 (0)