chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+259
View File
@@ -0,0 +1,259 @@
/**
* @vitest-environment node
*/
import type { AgentCard, Artifact, Message, Part, Task } from '@a2a-js/sdk'
import { Role, TaskState } from '@a2a-js/sdk'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/security/input-validation.server', () => ({
validateUrlWithDNS: vi.fn(),
secureFetchWithPinnedIP: vi.fn(),
}))
import {
agentCardOutput,
buildUserMessage,
extractText,
isTaskResult,
messageOutput,
taskErrored,
taskOutput,
} from '@/lib/a2a/client'
function textPart(value: string): Part {
return { content: { $case: 'text', value }, metadata: undefined, filename: '', mediaType: '' }
}
function message(role: Role, parts: Part[]): Message {
return {
messageId: 'm-1',
contextId: 'ctx-1',
taskId: 'task-1',
role,
parts,
metadata: undefined,
extensions: [],
referenceTaskIds: [],
}
}
function artifact(name: string, description: string, parts: Part[]): Artifact {
return {
artifactId: 'a-1',
name,
description,
parts,
metadata: undefined,
extensions: [],
} as unknown as Artifact
}
function task(opts: {
state?: TaskState
statusMessage?: Message
hasStatus?: boolean
history?: Message[]
artifacts?: Artifact[]
}): Task {
const hasStatus = opts.hasStatus ?? (opts.state !== undefined || opts.statusMessage !== undefined)
return {
id: 'task-1',
contextId: 'ctx-1',
status: hasStatus
? {
state: opts.state ?? TaskState.TASK_STATE_UNSPECIFIED,
message: opts.statusMessage,
timestamp: undefined,
}
: undefined,
artifacts: opts.artifacts ?? [],
history: opts.history ?? [],
metadata: undefined,
}
}
describe('buildUserMessage', () => {
it('builds a text-only user message', () => {
const m = buildUserMessage({ text: 'hello' })
expect(m.role).toBe(Role.ROLE_USER)
expect(m.parts).toHaveLength(1)
expect(m.parts[0].content).toEqual({ $case: 'text', value: 'hello' })
expect(m.taskId).toBe('')
expect(m.contextId).toBe('')
})
it('passes through taskId and contextId', () => {
const m = buildUserMessage({ text: 'hi', taskId: 't', contextId: 'c' })
expect(m.taskId).toBe('t')
expect(m.contextId).toBe('c')
})
it('appends a data part for structured data', () => {
const data = { foo: 'bar' }
const m = buildUserMessage({ text: 'hi', data })
expect(m.parts).toHaveLength(2)
expect(m.parts[1].content).toEqual({ $case: 'data', value: data })
})
it('builds a raw file part from resolved bytes', () => {
const bytes = new TextEncoder().encode('hello')
const m = buildUserMessage({
text: 'hi',
files: [{ bytes, name: 'f.txt', mediaType: 'text/plain' }],
})
const content = m.parts[1].content
expect(content?.$case).toBe('raw')
if (content?.$case === 'raw') expect(Buffer.from(content.value).toString()).toBe('hello')
expect(m.parts[1].filename).toBe('f.txt')
expect(m.parts[1].mediaType).toBe('text/plain')
})
})
describe('extractText', () => {
it('joins text parts and ignores non-text parts', () => {
const m = message(Role.ROLE_AGENT, [
textPart('a'),
{ content: { $case: 'data', value: {} }, metadata: undefined, filename: '', mediaType: '' },
textPart('b'),
])
expect(extractText(m)).toBe('a\nb')
})
})
describe('isTaskResult', () => {
it('treats objects with a status key as tasks', () => {
expect(isTaskResult(task({ state: TaskState.TASK_STATE_COMPLETED }))).toBe(true)
})
it('treats messages as non-tasks', () => {
expect(isTaskResult(message(Role.ROLE_AGENT, [textPart('hi')]))).toBe(false)
})
})
describe('taskErrored', () => {
it.each([
[TaskState.TASK_STATE_FAILED, true],
[TaskState.TASK_STATE_REJECTED, true],
[TaskState.TASK_STATE_COMPLETED, false],
[TaskState.TASK_STATE_INPUT_REQUIRED, false],
[TaskState.TASK_STATE_AUTH_REQUIRED, false],
[TaskState.TASK_STATE_CANCELED, false],
[TaskState.TASK_STATE_WORKING, false],
])('state %s -> errored %s', (state, expected) => {
expect(taskErrored(task({ state }))).toBe(expected)
})
it('is false when status is missing', () => {
expect(taskErrored(task({ hasStatus: false }))).toBe(false)
})
})
describe('taskOutput', () => {
it('maps state to a friendly label', () => {
expect(taskOutput(task({ state: TaskState.TASK_STATE_COMPLETED })).state).toBe('completed')
expect(taskOutput(task({ state: TaskState.TASK_STATE_INPUT_REQUIRED })).state).toBe(
'input-required'
)
})
it('uses the latest agent message from history for content', () => {
const out = taskOutput(
task({
state: TaskState.TASK_STATE_COMPLETED,
history: [
message(Role.ROLE_USER, [textPart('q')]),
message(Role.ROLE_AGENT, [textPart('first')]),
message(Role.ROLE_AGENT, [textPart('latest')]),
],
})
)
expect(out.content).toBe('latest')
})
it('falls back to the status message when history has no agent text', () => {
const out = taskOutput(
task({
state: TaskState.TASK_STATE_INPUT_REQUIRED,
statusMessage: message(Role.ROLE_AGENT, [textPart('need more info')]),
})
)
expect(out.content).toBe('need more info')
})
it('maps artifacts to flattened output', () => {
const out = taskOutput(
task({
state: TaskState.TASK_STATE_COMPLETED,
artifacts: [artifact('report', 'the report', [textPart('body')])],
})
)
expect(out.artifacts).toEqual([{ name: 'report', description: 'the report', content: 'body' }])
})
it('carries task and context ids', () => {
const out = taskOutput(task({ state: TaskState.TASK_STATE_COMPLETED }))
expect(out.taskId).toBe('task-1')
expect(out.contextId).toBe('ctx-1')
})
})
describe('messageOutput', () => {
it('maps a direct message to a completed output', () => {
const out = messageOutput(message(Role.ROLE_AGENT, [textPart('done')]))
expect(out).toEqual({
content: 'done',
taskId: 'task-1',
contextId: 'ctx-1',
state: 'completed',
artifacts: [],
})
})
})
describe('agentCardOutput', () => {
function makeCard(fields: {
supportedInterfaces?: Array<{ url: string; protocolVersion: string }>
skills?: Array<{ id: string; name: string; description: string }>
}): AgentCard {
return {
name: 'Agent',
description: 'desc',
version: '1.2.3',
supportedInterfaces: fields.supportedInterfaces ?? [],
capabilities: undefined,
skills: fields.skills ?? [],
defaultInputModes: [],
defaultOutputModes: [],
} as unknown as AgentCard
}
it('falls back to the agent url when there are no interfaces', () => {
const out = agentCardOutput(makeCard({}), 'https://fallback.example/a2a')
expect(out.url).toBe('https://fallback.example/a2a')
expect(out.protocolVersion).toBe('')
expect(out.capabilities).toEqual({
streaming: false,
pushNotifications: false,
extendedAgentCard: false,
})
})
it('prefers the first supported interface', () => {
const out = agentCardOutput(
makeCard({
supportedInterfaces: [{ url: 'https://agent.example/rpc', protocolVersion: '1.0' }],
}),
'https://fallback.example/a2a'
)
expect(out.url).toBe('https://agent.example/rpc')
expect(out.protocolVersion).toBe('1.0')
})
it('maps skills to id, name, and description', () => {
const out = agentCardOutput(
makeCard({ skills: [{ id: 's1', name: 'Search', description: 'searches' }] }),
'https://fallback.example/a2a'
)
expect(out.skills).toEqual([{ id: 's1', name: 'Search', description: 'searches' }])
})
})
+394
View File
@@ -0,0 +1,394 @@
import {
AGENT_CARD_PATH,
type AgentCard,
type Artifact,
type Message,
type Part,
Role,
type Task,
TaskState,
taskStateToJSON,
} from '@a2a-js/sdk'
import {
type BeforeArgs,
type CallInterceptor,
type Client,
ClientFactory,
ClientFactoryOptions,
DefaultAgentCardResolver,
JsonRpcTransportFactory,
RestTransportFactory,
} from '@a2a-js/sdk/client'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
const logger = createLogger('A2AClient')
/** Upper bound on a single agent HTTP response body, to bound memory on a hostile/large reply. */
const A2A_MAX_RESPONSE_BYTES = 10 * 1024 * 1024
/** Per-hop timeout for agent RPC calls; blocking sends may legitimately run long. */
const RPC_TIMEOUT_MS = 300_000
/** Shorter timeout for agent-card discovery so a slow or hanging agent fails fast. */
const CARD_RESOLUTION_TIMEOUT_MS = 30_000
/** How long a resolved agent card is reused before it is re-fetched. */
const CARD_CACHE_TTL_MS = 5 * 60 * 1000
/** Upper bound on cached cards; the oldest entry is evicted past this size. */
const CARD_CACHE_MAX_ENTRIES = 256
/** Well-known agent-card paths tried in order, ending with the URL itself. */
const CARD_CANDIDATE_PATHS = [AGENT_CARD_PATH, '/.well-known/agent.json', ''] as const
const agentCardCache = new Map<string, { card: AgentCard; expiresAt: number }>()
function getCachedCard(agentUrl: string): AgentCard | undefined {
const cached = agentCardCache.get(agentUrl)
if (!cached) return undefined
if (cached.expiresAt <= Date.now()) {
agentCardCache.delete(agentUrl)
return undefined
}
return cached.card
}
function cacheCard(agentUrl: string, card: AgentCard): void {
if (agentCardCache.size >= CARD_CACHE_MAX_ENTRIES) {
const oldest = agentCardCache.keys().next().value
if (oldest !== undefined) agentCardCache.delete(oldest)
}
agentCardCache.set(agentUrl, { card, expiresAt: Date.now() + CARD_CACHE_TTL_MS })
}
/** Attaches the `X-API-Key` header to every outgoing request. */
class ApiKeyInterceptor implements CallInterceptor {
constructor(private readonly apiKey: string) {}
before(args: BeforeArgs): Promise<void> {
args.options = {
...args.options,
serviceParameters: { ...args.options?.serviceParameters, 'X-API-Key': this.apiKey },
}
return Promise.resolve()
}
after(): Promise<void> {
return Promise.resolve()
}
}
/**
* Build a `fetch` bound to a DNS-validated, pinned IP so that calls to
* user-supplied agent URLs cannot be rebound to internal hosts (SSRF).
*
* Redirects are not followed: an authenticated agent call must not have its
* `X-API-Key` carried to a redirected host. A per-hop `timeout` and an optional
* caller `signal` bound how long the call can run and let the request abort
* propagate to the outbound connection.
*/
function createPinnedFetch(
resolvedIP: string,
config: { timeout: number; signal?: AbortSignal }
): typeof fetch {
return async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
const url = input instanceof Request ? input.url : input.toString()
const method = init?.method ?? (input instanceof Request ? input.method : undefined)
const rawHeaders = init?.headers ?? (input instanceof Request ? input.headers : undefined)
const headers =
rawHeaders instanceof Headers
? Object.fromEntries(rawHeaders.entries())
: Array.isArray(rawHeaders)
? Object.fromEntries(rawHeaders as string[][])
: (rawHeaders as Record<string, string> | undefined)
let body: string | Uint8Array | undefined
if (init?.body != null) {
if (typeof init.body === 'string' || init.body instanceof Uint8Array) {
body = init.body
} else if (init.body instanceof ArrayBuffer) {
body = new Uint8Array(init.body)
} else {
const text = await new Response(init.body as BodyInit).text()
if (text) body = text
}
} else if (input instanceof Request && !input.bodyUsed) {
const text = await input.text()
if (text) body = text
}
const callSignal =
config.signal ??
(init?.signal instanceof AbortSignal
? init.signal
: input instanceof Request && input.signal instanceof AbortSignal
? input.signal
: undefined)
const res = await secureFetchWithPinnedIP(url, resolvedIP, {
method,
headers,
body,
signal: callSignal,
timeout: config.timeout,
maxRedirects: 0,
maxResponseBytes: A2A_MAX_RESPONSE_BYTES,
})
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: new Headers(res.headers.toRecord()),
})
}
}
/**
* Resolve (and cache) an agent card. The card is resolved by trying the SDK
* default well-known path, the legacy pre-0.3 path, and finally the URL itself
* (some agents serve the card at the same URL they serve requests). Resolution
* uses a short timeout so a hanging agent fails fast; successful cards are
* cached per URL so subsequent operations skip the discovery round-trips.
*/
async function resolveAgentCard(
agentUrl: string,
resolvedIP: string,
signal?: AbortSignal
): Promise<AgentCard> {
const cached = getCachedCard(agentUrl)
if (cached) return cached
const resolver = new DefaultAgentCardResolver({
fetchImpl: createPinnedFetch(resolvedIP, { timeout: CARD_RESOLUTION_TIMEOUT_MS, signal }),
})
let lastError: unknown
for (const path of CARD_CANDIDATE_PATHS) {
try {
const card = await resolver.resolve(agentUrl, path)
cacheCard(agentUrl, card)
return card
} catch (error) {
lastError = error
logger.debug('Agent card resolution failed', {
agentUrl,
path,
error: toError(error).message,
})
}
}
throw toError(lastError)
}
/**
* Create an A2A client for an external agent URL. The URL is validated against
* SSRF and its DNS pinned on every call; an optional API key is sent via
* `X-API-Key`. The caller `signal` aborts the outbound connection when the
* request is cancelled.
*/
export async function createA2AClient(
agentUrl: string,
apiKey?: string,
options: { signal?: AbortSignal } = {}
): Promise<Client> {
const validation = await validateUrlWithDNS(agentUrl, 'agentUrl')
if (!validation.isValid || !validation.resolvedIP) {
throw new Error(validation.error || 'Agent URL validation failed')
}
const { resolvedIP } = validation
const card = await resolveAgentCard(agentUrl, resolvedIP, options.signal)
const pinnedFetch = createPinnedFetch(resolvedIP, {
timeout: RPC_TIMEOUT_MS,
signal: options.signal,
})
const factory = new ClientFactory(
ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
transports: [
new JsonRpcTransportFactory({ fetchImpl: pinnedFetch }),
new RestTransportFactory({ fetchImpl: pinnedFetch }),
],
...(apiKey ? { clientConfig: { interceptors: [new ApiKeyInterceptor(apiKey)] } } : {}),
})
)
return factory.createFromAgentCard(card)
}
/** A file to attach to an outgoing message, resolved to raw bytes from Sim storage. */
export interface A2AFileInput {
bytes: Uint8Array
name: string
mediaType: string
}
function textPart(value: string): Part {
return { content: { $case: 'text', value }, metadata: undefined, filename: '', mediaType: '' }
}
function dataPart(value: unknown): Part {
return { content: { $case: 'data', value }, metadata: undefined, filename: '', mediaType: '' }
}
function filePart(file: A2AFileInput): Part {
return {
content: { $case: 'raw', value: Buffer.from(file.bytes) },
metadata: undefined,
filename: file.name,
mediaType: file.mediaType,
}
}
/** Construct a user `Message` with a text part plus optional data and file parts. */
export function buildUserMessage(opts: {
text: string
data?: unknown
files?: A2AFileInput[]
taskId?: string
contextId?: string
}): Message {
const parts: Part[] = [textPart(opts.text)]
if (opts.data !== undefined) parts.push(dataPart(opts.data))
for (const file of opts.files ?? []) parts.push(filePart(file))
return {
messageId: generateId(),
contextId: opts.contextId ?? '',
taskId: opts.taskId ?? '',
role: Role.ROLE_USER,
parts,
metadata: undefined,
extensions: [],
referenceTaskIds: [],
}
}
function partsText(parts: Part[]): string {
return parts
.map((part) => (part.content?.$case === 'text' ? part.content.value : ''))
.filter(Boolean)
.join('\n')
}
/** Concatenated text of all text parts in a message. */
export function extractText(message: Message): string {
return partsText(message.parts)
}
function latestAgentText(task: Task): string {
const lastAgentMessage = task.history.filter((message) => message.role === Role.ROLE_AGENT).at(-1)
if (lastAgentMessage) return extractText(lastAgentMessage)
// Interrupted states (input-required, auth-required) carry the agent's prompt
// in the status message rather than the history.
const statusMessage = task.status?.message
return statusMessage ? extractText(statusMessage) : ''
}
/** A flattened artifact for block output. */
export interface A2AArtifactOutput {
name: string
description: string
content: string
}
function mapArtifacts(artifacts: Artifact[]): A2AArtifactOutput[] {
return artifacts.map((artifact) => ({
name: artifact.name,
description: artifact.description,
content: partsText(artifact.parts),
}))
}
function taskStateLabel(state: TaskState): string {
return taskStateToJSON(state)
.replace(/^TASK_STATE_/, '')
.toLowerCase()
.replace(/_/g, '-')
}
/** A send result is a `Task` when it carries status; otherwise it is a `Message`. */
export function isTaskResult(result: Message | Task): result is Task {
return 'status' in result
}
/** Normalized task fields for block output. */
export interface A2ATaskOutput {
content: string
taskId: string
contextId: string
state: string
artifacts: A2AArtifactOutput[]
}
export function taskOutput(task: Task): A2ATaskOutput {
const state = task.status?.state ?? TaskState.TASK_STATE_UNSPECIFIED
return {
content: latestAgentText(task),
taskId: task.id,
contextId: task.contextId,
state: taskStateLabel(state),
artifacts: mapArtifacts(task.artifacts),
}
}
/** Normalized output for a direct (non-task) message reply. */
export function messageOutput(message: Message): A2ATaskOutput {
return {
content: extractText(message),
taskId: message.taskId,
contextId: message.contextId,
state: 'completed',
artifacts: [],
}
}
/**
* True when the task ended in a hard-failure state (failed or rejected). Used to
* decide whether a send surfaces as a block error. Interrupted states such as
* input-required/auth-required are not failures — the caller branches on `state`.
*/
export function taskErrored(task: Task): boolean {
const state = task.status?.state ?? TaskState.TASK_STATE_UNSPECIFIED
return state === TaskState.TASK_STATE_FAILED || state === TaskState.TASK_STATE_REJECTED
}
/** Flattened agent card fields for block output. */
export interface A2AAgentCardOutput {
name: string
description: string
url: string
version: string
protocolVersion: string
capabilities: { streaming: boolean; pushNotifications: boolean; extendedAgentCard: boolean }
skills: Array<{ id: string; name: string; description: string }>
defaultInputModes: string[]
defaultOutputModes: string[]
}
export function agentCardOutput(card: AgentCard, fallbackUrl: string): A2AAgentCardOutput {
const iface = card.supportedInterfaces.at(0)
return {
name: card.name,
description: card.description,
url: iface?.url ?? fallbackUrl,
version: card.version,
protocolVersion: iface?.protocolVersion ?? '',
capabilities: {
streaming: card.capabilities?.streaming ?? false,
pushNotifications: card.capabilities?.pushNotifications ?? false,
extendedAgentCard: card.capabilities?.extendedAgentCard ?? false,
},
skills: card.skills.map((skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
})),
defaultInputModes: card.defaultInputModes,
defaultOutputModes: card.defaultOutputModes,
}
}