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
100 changes: 53 additions & 47 deletions sdk/typescript/src/agent/Agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import type {
DeploymentType,
HealthStatus,
ServerlessEvent,
ServerlessResponse
ServerlessResponse,
RawExecutionContext
} from '../types/agent.js';
import { ReasonerRegistry } from './ReasonerRegistry.js';
import { SkillRegistry } from './SkillRegistry.js';
Expand Down Expand Up @@ -43,16 +44,43 @@ import {
type ExecutionLogger
} from '../observability/ExecutionLogger.js';
import { LocalVerifier } from '../verification/LocalVerifier.js';
import type { Request, Response } from 'express';
import type { ParamsDictionary } from 'express-serve-static-core';
import {
installStdioLogCapture,
ProcessLogRing,
registerAgentfieldLogsRoute
} from './processLogs.js';

interface WildcardParams extends ParamsDictionary {
0: string;
}
class TargetNotFoundError extends Error {}

const harnessRunners = new WeakMap<object, HarnessRunner>();



function normalizeExecutionContext(
ctx: RawExecutionContext
): Partial<ExecutionMetadata> {
return {
executionId: ctx.executionId ?? ctx.execution_id,
runId: ctx.runId ?? ctx.run_id,
workflowId: ctx.workflowId ?? ctx.workflow_id,
parentExecutionId: ctx.parentExecutionId ?? ctx.parent_execution_id,
sessionId: ctx.sessionId ?? ctx.session_id,
actorId: ctx.actorId ?? ctx.actor_id,
callerDid: ctx.callerDid ?? ctx.caller_did,
targetDid: ctx.targetDid ?? ctx.target_did,
agentNodeDid: ctx.agentNodeDid ?? ctx.agent_node_did,

// ✅ ADD THESE
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.

Leftover dev comment — should be removed before merging.

Also, the reason you need as any on the next two lines is that RawExecutionContext is missing rootWorkflowId/root_workflow_id and reasonerId/reasoner_id. If you add those to the interface in types/agent.ts, these casts go away and the type is actually complete.

rootWorkflowId: (ctx as any).rootWorkflowId ?? (ctx as any).root_workflow_id,
reasonerId: (ctx as any).reasonerId ?? (ctx as any).reasoner_id
};
}

export class Agent {
readonly config: AgentConfig;
readonly app: express.Express;
Expand Down Expand Up @@ -739,10 +767,10 @@ export class Agent {
});
}

this.app.post('/api/v1/reasoners/*', (req, res) => this.executeReasoner(req, res, (req.params as any)[0]));
this.app.post('/api/v1/reasoners/*', (req: Request<WildcardParams>, res: Response) => this.executeReasoner(req, res, req.params[0]));
this.app.post('/reasoners/:name', (req, res) => this.executeReasoner(req, res, req.params.name));

this.app.post('/api/v1/skills/*', (req, res) => this.executeSkill(req, res, (req.params as any)[0]));
this.app.post('/api/v1/skills/*', (req: Request<WildcardParams>, res: Response) => this.executeSkill(req, res, req.params[0]));
this.app.post('/skills/:name', (req, res) => this.executeSkill(req, res, req.params.name));

// Serverless-friendly execute endpoint that accepts { target, input } or { reasoner, input }
Expand Down Expand Up @@ -875,7 +903,7 @@ export class Agent {

private handleHttpRequest(req: http.IncomingMessage | express.Request, res: http.ServerResponse | express.Response) {
const handler = this.app as unknown as (req: http.IncomingMessage, res: http.ServerResponse) => void;
return handler(req as any, res as any);
return handler(req as http.IncomingMessage, res as http.ServerResponse);
}

private async handleServerlessEvent(event: ServerlessEvent): Promise<ServerlessResponse> {
Expand All @@ -890,7 +918,7 @@ export class Agent {
};
}

const body = this.normalizeEventBody(event);
const body = event?.body !== undefined ? this.parseBody(event.body): event;
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.

This changes behavior, not just types. The old code called this.normalizeEventBody(event), which merged event.input into the parsed body when the body didn't already have an input key. This new inline version drops that logic entirely.

Serverless callers that send { target: "my-func", input: {...} } at the top level (without wrapping in body) will silently get the wrong input. This should still call normalizeEventBody — just with the as any cleaned up inside that method.

const invocation = this.extractInvocationDetails({
path,
query: event?.queryStringParameters,
Expand Down Expand Up @@ -938,48 +966,23 @@ export class Agent {
}

private normalizeEventBody(event: ServerlessEvent) {
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.

This method is now dead code — nothing calls it after the change on line 921. It also has completely different logic from the original (returns parsed.input directly instead of merging event.input into the body).

Either restore this as the call target in handleServerlessEvent (with the old merge behavior preserved) or delete it.

const parsed = this.parseBody((event as any)?.body);
if (parsed && typeof parsed === 'object' && event?.input !== undefined && (parsed as any).input === undefined) {
return { ...(parsed as Record<string, any>), input: event.input };
}
if ((parsed === undefined || parsed === null) && event?.input !== undefined) {
return { input: event.input };
interface ParsedBody {
input?: unknown;
data?: unknown;
[key: string]: unknown;
}

const parsed = this.parseBody(event?.body) as ParsedBody | undefined;

if (parsed?.input !== undefined) return parsed.input;
if (parsed?.data !== undefined) return parsed.data;

return parsed;
}

private mergeExecutionContext(event: ServerlessEvent): Partial<ExecutionMetadata> {
const ctx = (event?.executionContext ?? (event as any)?.execution_context) as Partial<
ExecutionMetadata & {
execution_id?: string;
run_id?: string;
workflow_id?: string;
root_workflow_id?: string;
parent_execution_id?: string;
reasoner_id?: string;
session_id?: string;
actor_id?: string;
caller_did?: string;
target_did?: string;
agent_node_did?: string;
}
>;

if (!ctx) return {};

return {
executionId: (ctx as any).executionId ?? ctx.execution_id ?? ctx.executionId,
runId: ctx.runId ?? (ctx as any).run_id,
workflowId: ctx.workflowId ?? (ctx as any).workflow_id,
rootWorkflowId: ctx.rootWorkflowId ?? (ctx as any).root_workflow_id,
parentExecutionId: ctx.parentExecutionId ?? (ctx as any).parent_execution_id,
reasonerId: ctx.reasonerId ?? (ctx as any).reasoner_id,
sessionId: ctx.sessionId ?? (ctx as any).session_id,
actorId: ctx.actorId ?? (ctx as any).actor_id,
callerDid: (ctx as any).callerDid ?? (ctx as any).caller_did,
targetDid: (ctx as any).targetDid ?? (ctx as any).target_did,
agentNodeDid: (ctx as any).agentNodeDid ?? (ctx as any).agent_node_did
};
const rawCtx = event?.executionContext ?? event?.execution_context;
return rawCtx ? normalizeExecutionContext(rawCtx) : {};
}

private extractInvocationDetails(params: {
Expand Down Expand Up @@ -1064,12 +1067,15 @@ export class Agent {

if (parsed && typeof parsed === 'object') {
const { target, reasoner, skill, type, targetType, ...rest } = parsed as Record<string, any>;
if ((parsed as any).input !== undefined) {
return (parsed as any).input;
}
if ((parsed as any).data !== undefined) {
return (parsed as any).data;
interface ParsedBody {
input?: any;
data?: any;
[key: string]: any;
}

const parsedBody = parsed as ParsedBody;
if (parsedBody.input !== undefined) return parsedBody.input;
if (parsedBody.data !== undefined) return parsedBody.data;
if (Object.keys(rest).length === 0) {
return {};
}
Expand Down
117 changes: 93 additions & 24 deletions sdk/typescript/src/ai/RateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,33 @@ import os from 'node:os';

export class RateLimitError extends Error {
retryAfter?: number;

constructor(message: string, retryAfter?: number) {
status?: number;
statusCode?: number;
response?: {
status?: number;
statusCode?: number;
status_code?: number;
headers?: Record<string, string>;
};

constructor(
message: string,
retryAfter?: number,
status?: number,
statusCode?: number,
response?: {
status?: number;
statusCode?: number;
status_code?: number;
headers?: Record<string, string>;
}
) {
super(message);
this.name = 'RateLimitError';
this.retryAfter = retryAfter;
this.status = status;
this.statusCode = statusCode;
this.response = response;
}
}

Expand Down Expand Up @@ -57,26 +79,38 @@ export class StatelessRateLimiter {

protected _isRateLimitError(error: unknown): boolean {
if (!error) return false;
const err = error as any;

const className = err?.constructor?.name;
if (className && className.includes('RateLimitError')) {
if (error instanceof RateLimitError) {
return true;
}

const response = err?.response;
if (
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.

Nit: the indentation is inconsistent through this block — mixes 2-space and 4-space, and the if (isRateLimitError(...)) block below is indented at a different level than the rest of the method body.

typeof error === 'object' &&
error !== null &&
typeof (error as { constructor?: { name?: string } }).constructor?.name === 'string' &&
(error as { constructor: { name: string } }).constructor.name.includes('RateLimitError')
) {
return true;
}

if (isRateLimitError(error)) {
const statusCandidates = [
err?.status,
err?.statusCode,
response?.status,
response?.statusCode,
response?.status_code
error.status,
error.statusCode,
error.response?.status,
error.response?.statusCode,
error.response?.status_code,
];
if (statusCandidates.some((code: any) => code === 429 || code === 503)) {
if (statusCandidates.some((code) => code === 429 || code === 503)) {
return true;
}

const message = String(err?.message ?? err ?? '').toLowerCase();
if (error.retryAfter !== undefined) {
return true;
}
}
const err = toError(error);
const message = err.message.toLowerCase();
const rateLimitKeywords = [
'rate limit',
'rate-limit',
Expand All @@ -98,26 +132,40 @@ export class StatelessRateLimiter {

protected _extractRetryAfter(error: unknown): number | undefined {
if (!error) return undefined;
const err = error as any;
if (error instanceof RateLimitError) {
if (error.retryAfter) {
return error.retryAfter;
}
}

const headers = err?.response?.headers ?? err?.response?.Headers ?? err?.response?.header;
if (isRateLimitError(error)) {
const headers = error.response?.headers;
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.

The old code had err?.response?.headers ?? err?.response?.Headers ?? err?.response?.header to handle different HTTP client conventions. This drops the Headers and header fallbacks. Some clients (like older Node http libs) use different casing — worth keeping those.

if (headers && typeof headers === 'object') {
const retryAfterKey = Object.keys(headers).find((k) => k.toLowerCase() === 'retry-after');
if (retryAfterKey) {
const value = Array.isArray(headers[retryAfterKey]) ? headers[retryAfterKey][0] : headers[retryAfterKey];
const parsed = parseFloat(value);
if (!Number.isNaN(parsed)) {
return parsed;
if (value !== undefined) {
const parsed = parseFloat(String(value));
if (!Number.isNaN(parsed)) {
return parsed;
}
}
}
}

const retryAfter = err?.retryAfter ?? err?.retry_after;
const parsed = parseFloat(retryAfter);
if (!Number.isNaN(parsed)) {
return parsed;
if (error.retryAfter !== undefined) {
const parsed = parseFloat(String(error.retryAfter));
if (!Number.isNaN(parsed)) {
return parsed;
}
}
}
const err = error as { retryAfter?: string | number };
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.

The old code also checked err?.retry_after (snake_case) as a fallback alongside retryAfter. That got dropped here. Third-party error objects that use Python-style naming won't have their retry timing extracted anymore.

if (err.retryAfter !== undefined) {
const parsed = parseFloat(String(err.retryAfter));
if (!Number.isNaN(parsed)) {
return parsed;
}
}

return undefined;
}

Expand Down Expand Up @@ -219,3 +267,24 @@ export class StatelessRateLimiter {
);
}
}


function toError(error: unknown): Error {
if (error instanceof Error) return error;
return new Error(String(error));
}


function isRateLimitError(error: unknown): error is RateLimitError {
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.

This type guard is pretty loose — any object with 'name' in error (which is every Error) plus 'status' in error will match. That means a regular HTTP 404 or 500 error with a status property would pass this check, and then _isRateLimitError would return true if the error also has a retryAfter property.

Might be safer to require that the status is specifically 429 or 503, or check for rate-limit-specific property combinations.

return (
typeof error === 'object' &&
error !== null &&
('name' in error || 'message' in error) &&
(
'status' in error ||
'statusCode' in error ||
'response' in error ||
'retryAfter' in error
)
);
}
10 changes: 8 additions & 2 deletions sdk/typescript/src/ai/ToolCalling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ export interface AIToolRequestOptions extends AIRequestOptions {
maxToolCalls?: number;
}

type ToolConfig = {
description: string
inputSchema: any;
execute?: (args: Record<string, unknown>) => Promise<any>;
}

// ---------------------------------------------------------------------------
// Capability -> Tool Definition Conversion
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -368,7 +374,7 @@ function wrapToolsWithObservability(
const observableTools: ToolSet = {};

for (const [name, t] of Object.entries(toolMap)) {
const originalTool = t as any;
const originalTool = t as ToolConfig;
observableTools[name] = tool({
description: originalTool.description ?? '',
inputSchema: originalTool.inputSchema,
Expand Down Expand Up @@ -443,7 +449,7 @@ export async function executeToolCallLoop(
// Create non-executable tool stubs so the LLM selects but doesn't execute
const selectionTools: ToolSet = {};
for (const [name, t] of Object.entries(toolMap)) {
const orig = t as any;
const orig = t as ToolConfig;
selectionTools[name] = tool({
description: orig.description ?? '',
inputSchema: orig.inputSchema,
Expand Down
Loading
Loading