agent-tool-forge 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/lib/agent-registry.js +170 -0
- package/lib/api-client.js +792 -0
- package/lib/api-loader.js +260 -0
- package/lib/auth.d.ts +25 -0
- package/lib/auth.js +158 -0
- package/lib/checks/check-adapter.js +172 -0
- package/lib/checks/compose.js +42 -0
- package/lib/checks/content-match.js +14 -0
- package/lib/checks/cost-budget.js +11 -0
- package/lib/checks/index.js +18 -0
- package/lib/checks/json-valid.js +15 -0
- package/lib/checks/latency.js +11 -0
- package/lib/checks/length-bounds.js +17 -0
- package/lib/checks/negative-match.js +14 -0
- package/lib/checks/no-hallucinated-numbers.js +63 -0
- package/lib/checks/non-empty.js +34 -0
- package/lib/checks/regex-match.js +12 -0
- package/lib/checks/run-checks.js +84 -0
- package/lib/checks/schema-match.js +26 -0
- package/lib/checks/tool-call-count.js +16 -0
- package/lib/checks/tool-selection.js +34 -0
- package/lib/checks/types.js +45 -0
- package/lib/comparison/compare.js +86 -0
- package/lib/comparison/format.js +104 -0
- package/lib/comparison/index.js +6 -0
- package/lib/comparison/statistics.js +59 -0
- package/lib/comparison/types.js +41 -0
- package/lib/config-schema.js +200 -0
- package/lib/config.d.ts +66 -0
- package/lib/conversation-store.d.ts +77 -0
- package/lib/conversation-store.js +443 -0
- package/lib/db.d.ts +6 -0
- package/lib/db.js +1112 -0
- package/lib/dep-check.js +99 -0
- package/lib/drift-background.js +61 -0
- package/lib/drift-monitor.js +187 -0
- package/lib/eval-runner.js +566 -0
- package/lib/fixtures/fixture-store.js +161 -0
- package/lib/fixtures/index.js +11 -0
- package/lib/forge-engine.js +982 -0
- package/lib/forge-eval-generator.js +417 -0
- package/lib/forge-file-writer.js +386 -0
- package/lib/forge-service-client.js +190 -0
- package/lib/forge-service.d.ts +4 -0
- package/lib/forge-service.js +655 -0
- package/lib/forge-verifier-generator.js +271 -0
- package/lib/handlers/admin.js +151 -0
- package/lib/handlers/agents.js +229 -0
- package/lib/handlers/chat-resume.js +334 -0
- package/lib/handlers/chat-sync.js +320 -0
- package/lib/handlers/chat.js +320 -0
- package/lib/handlers/conversations.js +92 -0
- package/lib/handlers/preferences.js +88 -0
- package/lib/handlers/tools-list.js +58 -0
- package/lib/hitl-engine.d.ts +60 -0
- package/lib/hitl-engine.js +261 -0
- package/lib/http-utils.js +92 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +141 -0
- package/lib/init.js +636 -0
- package/lib/manual-entry.js +59 -0
- package/lib/mcp-server.js +252 -0
- package/lib/output-groups.js +54 -0
- package/lib/postgres-store.d.ts +31 -0
- package/lib/postgres-store.js +465 -0
- package/lib/preference-store.d.ts +47 -0
- package/lib/preference-store.js +79 -0
- package/lib/prompt-store.d.ts +42 -0
- package/lib/prompt-store.js +60 -0
- package/lib/rate-limiter.d.ts +30 -0
- package/lib/rate-limiter.js +104 -0
- package/lib/react-engine.d.ts +110 -0
- package/lib/react-engine.js +337 -0
- package/lib/runner/cli.js +156 -0
- package/lib/runner/cost-estimator.js +71 -0
- package/lib/runner/gate.js +46 -0
- package/lib/runner/index.js +165 -0
- package/lib/sidecar.d.ts +83 -0
- package/lib/sidecar.js +161 -0
- package/lib/sse.d.ts +15 -0
- package/lib/sse.js +30 -0
- package/lib/tools-scanner.js +91 -0
- package/lib/tui.js +253 -0
- package/lib/verifier-report.js +78 -0
- package/lib/verifier-runner.js +338 -0
- package/lib/verifier-scanner.js +70 -0
- package/lib/verifier-worker-pool.js +196 -0
- package/lib/views/chat.js +340 -0
- package/lib/views/endpoints.js +203 -0
- package/lib/views/eval-run.js +206 -0
- package/lib/views/forge-agent.js +538 -0
- package/lib/views/forge.js +410 -0
- package/lib/views/main-menu.js +275 -0
- package/lib/views/mediation.js +381 -0
- package/lib/views/model-compare.js +430 -0
- package/lib/views/model-comparison.js +333 -0
- package/lib/views/onboarding.js +470 -0
- package/lib/views/performance.js +237 -0
- package/lib/views/run-evals.js +205 -0
- package/lib/views/settings.js +829 -0
- package/lib/views/tools-evals.js +514 -0
- package/lib/views/verifier-coverage.js +617 -0
- package/lib/workers/verifier-worker.js +52 -0
- package/package.json +123 -0
- package/widget/forge-chat.js +789 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface RateLimitResult {
|
|
2
|
+
allowed: boolean;
|
|
3
|
+
/** Seconds until the current window resets. Present only when `allowed` is false. */
|
|
4
|
+
retryAfter?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fixed-window per-user per-route rate limiter.
|
|
9
|
+
*
|
|
10
|
+
* Backend is selected automatically:
|
|
11
|
+
* - Redis — if a Redis client is provided (uses INCR + EXPIRE per window key)
|
|
12
|
+
* - Memory — in-process Map fallback, resets on window boundary
|
|
13
|
+
*
|
|
14
|
+
* Only counts authenticated requests — limits by `userId`, not IP.
|
|
15
|
+
*/
|
|
16
|
+
export class RateLimiter {
|
|
17
|
+
constructor(config?: object, redis?: object | null);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check whether a request is within the rate limit and increment the counter.
|
|
21
|
+
* Always returns `{ allowed: true }` when rate limiting is disabled.
|
|
22
|
+
*/
|
|
23
|
+
check(userId: string, route: string): Promise<RateLimitResult>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Factory — creates a RateLimiter from forge config.
|
|
28
|
+
* Reads `config.rateLimit` for `enabled`, `windowMs`, and `maxRequests`.
|
|
29
|
+
*/
|
|
30
|
+
export function makeRateLimiter(config: object, redis?: object | null): RateLimiter;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RateLimiter — fixed-window per-user per-route rate limiting.
|
|
3
|
+
*
|
|
4
|
+
* Auto-detects backend:
|
|
5
|
+
* Redis — if a Redis client is provided (INCR + EXPIRE per window key)
|
|
6
|
+
* Memory — fallback Map, resets on window boundary
|
|
7
|
+
*
|
|
8
|
+
* Only applied to authenticated requests — rate-limits by userId, not IP.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export class RateLimiter {
|
|
12
|
+
/**
|
|
13
|
+
* @param {object} config — forge rateLimit config block
|
|
14
|
+
* @param {object} [redis] — ioredis / node-redis-compatible client (optional)
|
|
15
|
+
*/
|
|
16
|
+
constructor(config = {}, redis = null) {
|
|
17
|
+
this._enabled = config.enabled ?? false;
|
|
18
|
+
this._windowMs = config.windowMs ?? 60_000;
|
|
19
|
+
this._maxRequests = config.maxRequests ?? 60;
|
|
20
|
+
this._redis = redis;
|
|
21
|
+
// In-memory fallback: Map<`${userId}:${route}`, { count, windowStart }>
|
|
22
|
+
this._store = new Map();
|
|
23
|
+
if (this._enabled) {
|
|
24
|
+
this._sweepTimer = setInterval(() => {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const windowMs = this._windowMs;
|
|
27
|
+
for (const [k, v] of this._store) {
|
|
28
|
+
if (Math.floor(now / windowMs) * windowMs !== v.windowStart) {
|
|
29
|
+
this._store.delete(k);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}, this._windowMs).unref();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if a request is allowed under the rate limit.
|
|
38
|
+
* Increments the counter and returns the decision synchronously (memory)
|
|
39
|
+
* or asynchronously (Redis).
|
|
40
|
+
*
|
|
41
|
+
* @param {string} userId
|
|
42
|
+
* @param {string} route
|
|
43
|
+
* @returns {Promise<{ allowed: boolean, retryAfter?: number }>}
|
|
44
|
+
*/
|
|
45
|
+
async check(userId, route) {
|
|
46
|
+
if (!this._enabled) return { allowed: true };
|
|
47
|
+
if (!userId || !route) return { allowed: true };
|
|
48
|
+
|
|
49
|
+
const windowMs = this._windowMs;
|
|
50
|
+
const maxRequests = this._maxRequests;
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const windowStart = Math.floor(now / windowMs) * windowMs;
|
|
53
|
+
// Use null-byte separator to prevent collisions when userId or route contains ':'
|
|
54
|
+
// (e.g. userId "user:admin" + route "chat" must not collide with userId "user" + route "admin:chat")
|
|
55
|
+
const key = `\x00${userId}\x00${route}`;
|
|
56
|
+
|
|
57
|
+
if (this._redis) {
|
|
58
|
+
return this._checkRedis(key, now, windowMs, maxRequests, windowStart);
|
|
59
|
+
}
|
|
60
|
+
return this._checkMemory(key, now, windowMs, maxRequests, windowStart);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @private */
|
|
64
|
+
async _checkRedis(key, now, windowMs, maxRequests, windowStart) {
|
|
65
|
+
const redisKey = `forge:rl:${key}:${windowStart}`;
|
|
66
|
+
const ttlSeconds = Math.ceil(windowMs / 1000);
|
|
67
|
+
const count = await this._redis.incr(redisKey);
|
|
68
|
+
if (count === 1) await this._redis.expire(redisKey, ttlSeconds);
|
|
69
|
+
if (count > maxRequests) {
|
|
70
|
+
const retryAfter = Math.max(1, Math.ceil((windowStart + windowMs - now) / 1000));
|
|
71
|
+
return { allowed: false, retryAfter };
|
|
72
|
+
}
|
|
73
|
+
return { allowed: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @private */
|
|
77
|
+
_checkMemory(key, now, windowMs, maxRequests, windowStart) {
|
|
78
|
+
const entry = this._store.get(key);
|
|
79
|
+
if (!entry || entry.windowStart !== windowStart) {
|
|
80
|
+
// Prune the stale entry before creating the new window entry to prevent unbounded Map growth.
|
|
81
|
+
if (entry) this._store.delete(key);
|
|
82
|
+
this._store.set(key, { count: 1, windowStart });
|
|
83
|
+
return { allowed: true };
|
|
84
|
+
}
|
|
85
|
+
entry.count += 1;
|
|
86
|
+
if (entry.count > maxRequests) {
|
|
87
|
+
const retryAfter = Math.max(1, Math.ceil((windowStart + windowMs - now) / 1000));
|
|
88
|
+
return { allowed: false, retryAfter };
|
|
89
|
+
}
|
|
90
|
+
return { allowed: true };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Factory — creates a RateLimiter from forge config.
|
|
96
|
+
* Auto-passes Redis client if available (set by buildSidecarContext).
|
|
97
|
+
*
|
|
98
|
+
* @param {object} config — merged forge config
|
|
99
|
+
* @param {object} [redis] — optional Redis client
|
|
100
|
+
* @returns {RateLimiter}
|
|
101
|
+
*/
|
|
102
|
+
export function makeRateLimiter(config, redis = null) {
|
|
103
|
+
return new RateLimiter(config.rateLimit ?? {}, redis);
|
|
104
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export type ReactEventType =
|
|
2
|
+
| 'text'
|
|
3
|
+
| 'text_delta'
|
|
4
|
+
| 'tool_call'
|
|
5
|
+
| 'tool_result'
|
|
6
|
+
| 'tool_warning'
|
|
7
|
+
| 'hitl'
|
|
8
|
+
| 'error'
|
|
9
|
+
| 'done';
|
|
10
|
+
|
|
11
|
+
export interface TextEvent {
|
|
12
|
+
type: 'text';
|
|
13
|
+
content: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TextDeltaEvent {
|
|
17
|
+
type: 'text_delta';
|
|
18
|
+
content: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ToolCallEvent {
|
|
22
|
+
type: 'tool_call';
|
|
23
|
+
id: string;
|
|
24
|
+
tool: string;
|
|
25
|
+
args: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ToolResultEvent {
|
|
29
|
+
type: 'tool_result';
|
|
30
|
+
id: string;
|
|
31
|
+
tool?: string;
|
|
32
|
+
result: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ToolWarningEvent {
|
|
36
|
+
type: 'tool_warning';
|
|
37
|
+
tool: string;
|
|
38
|
+
message: string;
|
|
39
|
+
verifier?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HitlEvent {
|
|
43
|
+
type: 'hitl';
|
|
44
|
+
tool?: string;
|
|
45
|
+
message?: string;
|
|
46
|
+
resumeToken?: string;
|
|
47
|
+
verifier?: string;
|
|
48
|
+
conversationMessages?: unknown[];
|
|
49
|
+
pendingToolCalls?: unknown[];
|
|
50
|
+
turnIndex?: number;
|
|
51
|
+
args?: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ErrorEvent {
|
|
55
|
+
type: 'error';
|
|
56
|
+
message: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DoneEvent {
|
|
60
|
+
type: 'done';
|
|
61
|
+
usage?: {
|
|
62
|
+
inputTokens?: number;
|
|
63
|
+
outputTokens?: number;
|
|
64
|
+
[key: string]: unknown;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ReactEvent =
|
|
69
|
+
| TextEvent
|
|
70
|
+
| TextDeltaEvent
|
|
71
|
+
| ToolCallEvent
|
|
72
|
+
| ToolResultEvent
|
|
73
|
+
| ToolWarningEvent
|
|
74
|
+
| HitlEvent
|
|
75
|
+
| ErrorEvent
|
|
76
|
+
| DoneEvent;
|
|
77
|
+
|
|
78
|
+
export interface ReactMessage {
|
|
79
|
+
role: 'user' | 'assistant' | 'tool';
|
|
80
|
+
content: unknown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ReactLoopParams {
|
|
84
|
+
provider: string;
|
|
85
|
+
apiKey: string;
|
|
86
|
+
model: string;
|
|
87
|
+
systemPrompt: string;
|
|
88
|
+
messages: ReactMessage[];
|
|
89
|
+
tools?: unknown[];
|
|
90
|
+
maxTurns?: number;
|
|
91
|
+
maxTokens?: number;
|
|
92
|
+
forgeConfig?: object;
|
|
93
|
+
db?: object | null;
|
|
94
|
+
userJwt?: string | null;
|
|
95
|
+
stream?: boolean;
|
|
96
|
+
hooks?: {
|
|
97
|
+
shouldPause?: (toolMeta: object) => { pause: boolean; message?: string };
|
|
98
|
+
onAfterToolCall?: (toolName: string, args: object, result: unknown) => Promise<{ outcome: 'pass' | 'warn' | 'block'; message?: string | null; verifierName?: string }>;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function reactLoop(params: ReactLoopParams): AsyncIterable<ReactEvent>;
|
|
103
|
+
|
|
104
|
+
export function executeToolCall(
|
|
105
|
+
toolName: string,
|
|
106
|
+
args: object,
|
|
107
|
+
forgeConfig: object,
|
|
108
|
+
db: object | null,
|
|
109
|
+
userJwt: string | null
|
|
110
|
+
): Promise<{ status: number; body: object; error: string | null }>;
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReAct Engine — standalone ReAct loop module.
|
|
3
|
+
*
|
|
4
|
+
* Async generator that yields typed events. Transport-agnostic:
|
|
5
|
+
* caller converts events to SSE, console output, or whatever.
|
|
6
|
+
*
|
|
7
|
+
* Event types:
|
|
8
|
+
* text — assistant text chunk
|
|
9
|
+
* tool_call — assistant wants to call a tool
|
|
10
|
+
* tool_result — tool execution result
|
|
11
|
+
* tool_warning — verifier returned 'warn' outcome
|
|
12
|
+
* hitl — loop paused for human confirmation
|
|
13
|
+
* done — loop complete (includes usage summary)
|
|
14
|
+
* error — unrecoverable error
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { llmTurn, llmTurnStreaming, normalizeUsage } from './api-client.js';
|
|
18
|
+
import { getAllToolRegistry, insertMcpCallLog } from './db.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {'text'|'text_delta'|'tool_call'|'tool_result'|'tool_warning'|'hitl'|'done'|'error'} ReactEventType
|
|
22
|
+
* @typedef {{ type: ReactEventType, content?, tool?, args?, result?, resumeToken?, message?, usage? }} ReactEvent
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run a ReAct loop. Yields ReactEvent objects via async generator.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} opts
|
|
29
|
+
* @param {string} opts.provider
|
|
30
|
+
* @param {string} opts.apiKey
|
|
31
|
+
* @param {string} opts.model
|
|
32
|
+
* @param {string} opts.systemPrompt
|
|
33
|
+
* @param {object[]} opts.tools — forge-format tool defs
|
|
34
|
+
* @param {object[]} opts.messages — conversation history
|
|
35
|
+
* @param {number} [opts.maxTurns=10] — safety limit
|
|
36
|
+
* @param {number} [opts.maxTokens=4096] — per-turn
|
|
37
|
+
* @param {object} opts.forgeConfig — for tool routing
|
|
38
|
+
* @param {Database} opts.db — for tool registry reads + MCP log writes
|
|
39
|
+
* @param {string|null} [opts.userJwt] — forwarded to tool HTTP calls
|
|
40
|
+
* @param {boolean} [opts.stream=false] — enable token-level streaming
|
|
41
|
+
* @param {object} [opts.hooks] — { shouldPause, onAfterToolCall }
|
|
42
|
+
* @yields {ReactEvent}
|
|
43
|
+
*/
|
|
44
|
+
export async function* reactLoop(opts) {
|
|
45
|
+
const {
|
|
46
|
+
provider, apiKey, model, systemPrompt, tools, messages,
|
|
47
|
+
maxTurns = 10, maxTokens = 4096,
|
|
48
|
+
forgeConfig = {}, db = null, userJwt = null,
|
|
49
|
+
hooks = {}, stream = false
|
|
50
|
+
} = opts;
|
|
51
|
+
|
|
52
|
+
const shouldPause = hooks.shouldPause ?? (() => ({ pause: false }));
|
|
53
|
+
const onAfterToolCall = hooks.onAfterToolCall ?? (() => ({ outcome: 'pass' }));
|
|
54
|
+
|
|
55
|
+
const conversationMessages = [...messages];
|
|
56
|
+
let totalUsage = { inputTokens: 0, outputTokens: 0 };
|
|
57
|
+
|
|
58
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
59
|
+
let response;
|
|
60
|
+
try {
|
|
61
|
+
if (stream) {
|
|
62
|
+
let fullText = '';
|
|
63
|
+
const streamGen = llmTurnStreaming({
|
|
64
|
+
provider, apiKey, model,
|
|
65
|
+
system: systemPrompt,
|
|
66
|
+
messages: conversationMessages,
|
|
67
|
+
tools,
|
|
68
|
+
maxTokens
|
|
69
|
+
});
|
|
70
|
+
for await (const chunk of streamGen) {
|
|
71
|
+
if (chunk.type === 'text_delta') {
|
|
72
|
+
yield { type: 'text_delta', content: chunk.text };
|
|
73
|
+
fullText += chunk.text;
|
|
74
|
+
} else if (chunk.type === 'done') {
|
|
75
|
+
fullText = chunk.text; // authoritative
|
|
76
|
+
response = {
|
|
77
|
+
text: fullText,
|
|
78
|
+
toolCalls: chunk.toolCalls,
|
|
79
|
+
rawContent: null,
|
|
80
|
+
stopReason: chunk.stopReason,
|
|
81
|
+
usage: chunk.usage
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!response) {
|
|
86
|
+
yield { type: 'error', message: 'LLM stream ended without completion' };
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
response = await llmTurn({
|
|
91
|
+
provider, apiKey, model,
|
|
92
|
+
system: systemPrompt,
|
|
93
|
+
messages: conversationMessages,
|
|
94
|
+
tools,
|
|
95
|
+
maxTokens
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
yield { type: 'error', message: `LLM call failed: ${err.message}` };
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Accumulate usage
|
|
104
|
+
if (response.usage) {
|
|
105
|
+
const normalized = normalizeUsage(response.usage, provider);
|
|
106
|
+
totalUsage.inputTokens += normalized.inputTokens;
|
|
107
|
+
totalUsage.outputTokens += normalized.outputTokens;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Emit text if present
|
|
111
|
+
if (response.text) {
|
|
112
|
+
yield { type: 'text', content: response.text };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// If no tool calls, the loop is done
|
|
116
|
+
if (!response.toolCalls || response.toolCalls.length === 0) {
|
|
117
|
+
yield { type: 'done', usage: totalUsage };
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Process tool calls
|
|
122
|
+
const toolResults = [];
|
|
123
|
+
for (const toolCall of response.toolCalls) {
|
|
124
|
+
yield { type: 'tool_call', tool: toolCall.name, args: toolCall.input, id: toolCall.id };
|
|
125
|
+
|
|
126
|
+
// Check HITL before execution
|
|
127
|
+
let pauseCheck;
|
|
128
|
+
try { pauseCheck = shouldPause(toolCall); } catch { pauseCheck = { pause: false }; }
|
|
129
|
+
if (pauseCheck.pause) {
|
|
130
|
+
yield {
|
|
131
|
+
type: 'hitl',
|
|
132
|
+
tool: toolCall.name,
|
|
133
|
+
args: toolCall.input,
|
|
134
|
+
message: pauseCheck.message ?? 'Tool call requires confirmation',
|
|
135
|
+
pendingToolCalls: response.toolCalls,
|
|
136
|
+
conversationMessages: [...conversationMessages],
|
|
137
|
+
turnIndex: turn
|
|
138
|
+
};
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Execute tool
|
|
143
|
+
let result;
|
|
144
|
+
try {
|
|
145
|
+
result = await executeToolCall(toolCall.name, toolCall.input, forgeConfig, db, userJwt);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
result = { status: 0, body: { error: err.message }, error: err.message };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
yield { type: 'tool_result', tool: toolCall.name, result: result.body, id: toolCall.id };
|
|
151
|
+
|
|
152
|
+
// Run verifiers
|
|
153
|
+
let verifyResult;
|
|
154
|
+
try { verifyResult = await onAfterToolCall(toolCall.name, toolCall.input, result); } catch { verifyResult = { outcome: 'pass' }; }
|
|
155
|
+
if (verifyResult.outcome === 'warn') {
|
|
156
|
+
yield { type: 'tool_warning', tool: toolCall.name, message: verifyResult.message, verifier: verifyResult.verifierName };
|
|
157
|
+
} else if (verifyResult.outcome === 'block') {
|
|
158
|
+
yield {
|
|
159
|
+
type: 'hitl',
|
|
160
|
+
tool: toolCall.name,
|
|
161
|
+
message: verifyResult.message ?? 'Verifier blocked tool result',
|
|
162
|
+
verifier: verifyResult.verifierName,
|
|
163
|
+
pendingToolCalls: response.toolCalls,
|
|
164
|
+
conversationMessages: [...conversationMessages],
|
|
165
|
+
turnIndex: turn
|
|
166
|
+
};
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
toolResults.push({ toolCall, result });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Add tool call history in the correct format for the provider
|
|
174
|
+
if (provider === 'anthropic') {
|
|
175
|
+
// Anthropic expects assistant message with content array, then user message with tool_result blocks
|
|
176
|
+
conversationMessages.push({
|
|
177
|
+
role: 'assistant',
|
|
178
|
+
content: [
|
|
179
|
+
...(response.text ? [{ type: 'text', text: response.text }] : []),
|
|
180
|
+
...toolResults.map(({ toolCall }) => ({
|
|
181
|
+
type: 'tool_use',
|
|
182
|
+
id: toolCall.id,
|
|
183
|
+
name: toolCall.name,
|
|
184
|
+
input: toolCall.input
|
|
185
|
+
}))
|
|
186
|
+
]
|
|
187
|
+
});
|
|
188
|
+
conversationMessages.push({
|
|
189
|
+
role: 'user',
|
|
190
|
+
content: toolResults.map(({ toolCall, result }) => ({
|
|
191
|
+
type: 'tool_result',
|
|
192
|
+
tool_use_id: toolCall.id,
|
|
193
|
+
content: JSON.stringify(result.body)
|
|
194
|
+
}))
|
|
195
|
+
});
|
|
196
|
+
} else {
|
|
197
|
+
// OpenAI-compatible: assistant message with tool_calls array, then individual tool role messages
|
|
198
|
+
conversationMessages.push({
|
|
199
|
+
role: 'assistant',
|
|
200
|
+
content: response.text || null,
|
|
201
|
+
tool_calls: toolResults.map(({ toolCall }) => ({
|
|
202
|
+
id: toolCall.id,
|
|
203
|
+
type: 'function',
|
|
204
|
+
function: { name: toolCall.name, arguments: JSON.stringify(toolCall.input) }
|
|
205
|
+
}))
|
|
206
|
+
});
|
|
207
|
+
for (const { toolCall, result } of toolResults) {
|
|
208
|
+
conversationMessages.push({
|
|
209
|
+
role: 'tool',
|
|
210
|
+
tool_call_id: toolCall.id,
|
|
211
|
+
content: JSON.stringify(result.body)
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Safety limit reached
|
|
218
|
+
yield { type: 'error', message: `ReAct loop reached maxTurns limit (${maxTurns})` };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Execute a single tool call — routes to the app's API endpoint.
|
|
223
|
+
* Adapted from mcp-server.js callToolEndpoint for direct use by the ReAct loop.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} toolName
|
|
226
|
+
* @param {object} args
|
|
227
|
+
* @param {object} forgeConfig — forge config with api.baseUrl
|
|
228
|
+
* @param {Database|null} db — for MCP call logging
|
|
229
|
+
* @param {string|null} userJwt — forwarded as Authorization header
|
|
230
|
+
* @returns {Promise<{ status: number, body: object, error: string|null }>}
|
|
231
|
+
*/
|
|
232
|
+
export async function executeToolCall(toolName, args, forgeConfig, db, userJwt) {
|
|
233
|
+
// Look up tool spec in registry
|
|
234
|
+
let spec;
|
|
235
|
+
if (db) {
|
|
236
|
+
const rows = getAllToolRegistry(db).filter(r => r.lifecycle_state === 'promoted');
|
|
237
|
+
const row = rows.find(r => r.tool_name === toolName);
|
|
238
|
+
if (!row) {
|
|
239
|
+
return { status: 404, body: { error: `Tool "${toolName}" not found in registry` }, error: 'Tool not found' };
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
spec = JSON.parse(row.spec_json);
|
|
243
|
+
} catch {
|
|
244
|
+
return { status: 500, body: { error: `Tool "${toolName}" has malformed spec_json` }, error: 'Malformed spec' };
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
return { status: 500, body: { error: 'No database available for tool lookup' }, error: 'No db' };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const routing = spec.mcpRouting || {};
|
|
251
|
+
if (!routing.endpoint) {
|
|
252
|
+
return { status: 400, body: { error: `Tool "${toolName}" has no mcpRouting.endpoint` }, error: 'No endpoint' };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const baseUrl = (forgeConfig.api?.baseUrl || 'http://localhost:3000').replace(/\/$/, '');
|
|
256
|
+
const path = routing.endpoint;
|
|
257
|
+
const method = (routing.method || 'GET').toUpperCase();
|
|
258
|
+
const paramMap = routing.paramMap || {};
|
|
259
|
+
|
|
260
|
+
// Build URL with path params substituted; collect query and body params
|
|
261
|
+
let url = baseUrl + path;
|
|
262
|
+
const queryParams = new URLSearchParams();
|
|
263
|
+
const bodyObj = {};
|
|
264
|
+
|
|
265
|
+
for (const [toolParam, mapping] of Object.entries(paramMap)) {
|
|
266
|
+
const val = args[toolParam];
|
|
267
|
+
if (val === undefined) continue;
|
|
268
|
+
if (mapping.path) {
|
|
269
|
+
url = url.replace(`{${mapping.path}}`, encodeURIComponent(String(val)));
|
|
270
|
+
} else if (mapping.query) {
|
|
271
|
+
queryParams.set(mapping.query, String(val));
|
|
272
|
+
} else if (mapping.body) {
|
|
273
|
+
bodyObj[mapping.body] = val;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if ([...queryParams].length > 0) url += '?' + queryParams.toString();
|
|
278
|
+
|
|
279
|
+
const fetchOpts = {
|
|
280
|
+
method,
|
|
281
|
+
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
282
|
+
signal: AbortSignal.timeout(30_000)
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// Forward user JWT if present
|
|
286
|
+
if (userJwt) {
|
|
287
|
+
fetchOpts.headers['Authorization'] = `Bearer ${userJwt}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (['POST', 'PUT', 'PATCH'].includes(method) && Object.keys(bodyObj).length > 0) {
|
|
291
|
+
fetchOpts.body = JSON.stringify(bodyObj);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const startMs = Date.now();
|
|
295
|
+
try {
|
|
296
|
+
const res = await fetch(url, fetchOpts);
|
|
297
|
+
const text = await res.text();
|
|
298
|
+
const latencyMs = Date.now() - startMs;
|
|
299
|
+
|
|
300
|
+
let body;
|
|
301
|
+
try { body = JSON.parse(text); } catch { body = { text }; }
|
|
302
|
+
|
|
303
|
+
// Log to MCP call log
|
|
304
|
+
if (db) {
|
|
305
|
+
try {
|
|
306
|
+
insertMcpCallLog(db, {
|
|
307
|
+
tool_name: toolName,
|
|
308
|
+
input_json: JSON.stringify(args),
|
|
309
|
+
output_json: text.slice(0, 10_000),
|
|
310
|
+
status_code: res.status,
|
|
311
|
+
latency_ms: latencyMs,
|
|
312
|
+
error: res.ok ? null : text.slice(0, 500)
|
|
313
|
+
});
|
|
314
|
+
} catch { /* log failure is non-fatal */ }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
status: res.status,
|
|
319
|
+
body,
|
|
320
|
+
error: res.ok ? null : `HTTP ${res.status}: ${text.slice(0, 200)}`
|
|
321
|
+
};
|
|
322
|
+
} catch (err) {
|
|
323
|
+
const latencyMs = Date.now() - startMs;
|
|
324
|
+
if (db) {
|
|
325
|
+
try {
|
|
326
|
+
insertMcpCallLog(db, {
|
|
327
|
+
tool_name: toolName,
|
|
328
|
+
input_json: JSON.stringify(args),
|
|
329
|
+
status_code: 0,
|
|
330
|
+
latency_ms: latencyMs,
|
|
331
|
+
error: err.message
|
|
332
|
+
});
|
|
333
|
+
} catch { /* log failure is non-fatal */ }
|
|
334
|
+
}
|
|
335
|
+
return { status: 0, body: { error: err.message }, error: err.message };
|
|
336
|
+
}
|
|
337
|
+
}
|