Skip to content
Merged
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
6 changes: 3 additions & 3 deletions admin/slices/agent/peer/components/peer/CardView.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { IAgentCard } from '#peer/stores/peer';
import { cardAddress } from '#peer/domain';

/**
* An agent card, rendered as the agent on the other side reads it (CLEAN-74).
Expand All @@ -17,9 +18,8 @@ const props = defineProps<{

const skills = computed(() => props.card?.skills ?? []);

const address = computed(
() => props.card?.supportedInterfaces?.[0]?.url ?? null,
);
// The interface delegations will dial, not merely the first one listed.
const address = computed(() => cardAddress(props.card));

function skillTag(tags: string[]): string | null {
if (tags.includes('knowledge')) return 'knowledge';
Expand Down
3 changes: 3 additions & 0 deletions admin/slices/agent/peer/components/peer/Delegations.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const CAUSES: Record<string, string> = {
PEER_UNAUTHORIZED: 'credential refused',
PEER_UNREACHABLE: 'could not be reached',
PEER_ERROR: 'error',
// CLEAN-97: nothing was sent, so the peer did not fail — the card did.
PEER_ADDRESS_REFUSED: 'refused: private address',
PEER_UNSUPPORTED: 'no JSON-RPC interface',
};

const live = ref(true);
Expand Down
5 changes: 2 additions & 3 deletions admin/slices/agent/peer/components/peer/Tab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { toast } from 'vue-sonner';
import { useAgentStore } from '#agent/stores/agent';
import type { IAgentData } from '#agent/domain';
import { usePeerStore, type IAgentPeer } from '#peer/stores/peer';
import { cardAddress } from '#peer/domain';

/**
* The Peers tab (CLEAN-74): what this agent advertises, who it can delegate
Expand Down Expand Up @@ -40,9 +41,7 @@ const armed = computed(() =>
);
const restarting = ref(false);

const address = computed(
() => ownCard.value?.supportedInterfaces?.[0]?.url ?? null,
);
const address = computed(() => cardAddress(ownCard.value));
const nothingAdvertised = computed(
() => Boolean(ownCard.value) && !ownCard.value!.skills.length,
);
Expand Down
22 changes: 22 additions & 0 deletions admin/slices/agent/peer/domain/cardAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { IAgentCard } from './peer.types';

/**
* The address a card is actually called at (CLEAN-97): its first JSON-RPC
* interface on A2A 1.0 — the same rule the API applies when it imports a card
* and when it delegates. Showing the first listed interface instead would put
* an HTTP+JSON or gRPC address in front of the operator while delegations go
* somewhere else.
*
* Falls back to the first interface for a card that offers no JSON-RPC 1.0
* at all, so the operator still sees where it lives; the API refuses to
* import such a card anyway.
*/
export function cardAddress(card: IAgentCard | null | undefined): string | null {
const interfaces = card?.supportedInterfaces ?? [];
const jsonRpc = interfaces.find(
(i) =>
String(i?.protocolBinding ?? '').toUpperCase() === 'JSONRPC' &&
i?.protocolVersion === '1.0',
);
return jsonRpc?.url ?? interfaces[0]?.url ?? null;
}
1 change: 1 addition & 0 deletions admin/slices/agent/peer/domain/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './peer.types';
export * from './peer.service';
export * from './cardAddress';
26 changes: 25 additions & 1 deletion api/src/slices/agent/peer/askAgent.tool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,9 @@ describe('AskAgentTool — the description the model reads', () => {
const description = (await tool.describeForRequest(agentRequest())) ?? '';

// FR-011: the model must try a plausible peer before saying I don't know.
const giveUp = description.search(/before you answer that you do not know/i);
const giveUp = description.search(
/before you answer that you do not know/i,
);
const caveat = description.search(/not a first resort/i);
expect(giveUp).toBeGreaterThanOrEqual(0);
expect(caveat).toBeGreaterThan(giveUp);
Expand Down Expand Up @@ -298,6 +300,28 @@ describe('AskAgentTool — calling it', () => {
expect(text).toContain('Shoes can be returned within 30 days.');
});

it('says outright that a peer answered with nothing, so the model does not fill the gap (CLEAN-97)', async () => {
const { tool, args } = makeHarness({
outcome: {
kind: 'done',
status: DelegationStatuses.Answered,
peerName: 'Support Bot',
contextId: 'ctx-1',
durationMs: 800,
text: '',
},
});

const result = await tool.ask(args, null, agentRequest());

expect(result.isError).toBeUndefined();
const text = textOf(result);
expect(text).toContain('«Support Bot» answered');
expect(text).toContain('the reply was empty');
expect(text).toMatch(/do not invent/i);
expect(text).not.toContain('Reply from');
});

it('tells the model not to answer for a peer it could not reach', async () => {
const { tool, args } = makeHarness({
outcome: {
Expand Down
13 changes: 11 additions & 2 deletions api/src/slices/agent/peer/askAgent.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,15 @@ export class AskAgentTool
const took = `${(outcome.durationMs / 1000).toFixed(1)}s`;

if (outcome.status === DelegationStatuses.Answered) {
if (!outcome.text) {
// Said explicitly: an empty tool result reads to a model like "no
// news", and it will fill the gap itself (CLEAN-97).
return ok(
`«${outcome.peerName}» answered (context_id: ${outcome.contextId}, ${took}), but the reply was empty — no text, data or links. Tell the user it returned nothing; do not invent what it might have said.`,
);
}
return ok(
`Reply from «${outcome.peerName}» (context_id: ${outcome.contextId}, ${took}):\n\n${outcome.text ?? ''}`,
`Reply from «${outcome.peerName}» (context_id: ${outcome.contextId}, ${took}):\n\n${outcome.text}`,
);
}

Expand Down Expand Up @@ -234,7 +241,9 @@ function describePeer(connection: IAgentPeerData): string {

// External peers have no agent id here; the connection id names them just
// as reliably — matchPeer resolves both (CLEAN-95).
const parts = [`- "${name}" (peer: ${connection.peerAgentId ?? connection.id})`];
const parts = [
`- "${name}" (peer: ${connection.peerAgentId ?? connection.id})`,
];
if (description) parts.push(` — ${description}`);
if (skills) parts.push(` Skills: ${skills}`);
return parts.join('');
Expand Down
160 changes: 145 additions & 15 deletions api/src/slices/agent/peer/domain/a2a.client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,17 @@ function respond(options: {
json?: unknown;
text?: string;
jsonThrows?: boolean;
location?: string;
}) {
const status = options.status ?? 200;
return {
ok: options.ok ?? (status >= 200 && status < 300),
status,
type: 'basic',
headers: {
get: (name: string) =>
name.toLowerCase() === 'location' ? (options.location ?? null) : null,
},
json: async () => {
if (options.jsonThrows) throw new Error('not json');
return options.json;
Expand Down Expand Up @@ -86,10 +92,29 @@ describe('A2aClient.fetchCard', () => {
CARD_URL,
expect.objectContaining({
headers: expect.objectContaining({ Authorization: `Bearer ${TOKEN}` }),
// Redirects are an SSRF-guard bypass; the client refuses them.
redirect: 'error',
// Redirects are an SSRF-guard bypass: never followed, only reported.
redirect: 'manual',
}),
);
});

it('refuses a redirecting card and names where it points, without going there', async () => {
fetchMock.mockResolvedValue(
respond({
status: 301,
location: 'https://agent.example/.well-known/agent-card.json',
}),
);

await expect(
new A2aClient().fetchCard(CARD_URL, TOKEN),
).rejects.toMatchObject({
kind: 'invalid',
message: expect.stringContaining(
'redirects to https://agent.example/.well-known/agent-card.json',
),
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('reports an unreachable host in words an operator can act on', async () => {
Expand All @@ -105,16 +130,19 @@ describe('A2aClient.fetchCard', () => {

await new A2aClient().fetchCard(CARD_URL);

const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>;
const headers = fetchMock.mock.calls[0][1].headers as Record<
string,
string
>;
expect(headers).not.toHaveProperty('Authorization');
});

it('marks not-a-card answers as invalid, not unreachable (CLEAN-95)', async () => {
fetchMock.mockResolvedValue(respond({ json: { hello: 'world' } }));

await expect(new A2aClient().fetchCard(CARD_URL, TOKEN)).rejects.toMatchObject(
{ kind: 'invalid' },
);
await expect(
new A2aClient().fetchCard(CARD_URL, TOKEN),
).rejects.toMatchObject({ kind: 'invalid' });
});

it('names a timeout as a timeout rather than an abort', async () => {
Expand Down Expand Up @@ -149,9 +177,40 @@ describe('A2aClient.fetchCard', () => {
it('refuses a document that parses but is not an agent card', async () => {
fetchMock.mockResolvedValue(respond({ json: { hello: 'world' } }));

await expect(new A2aClient().fetchCard(CARD_URL, TOKEN)).rejects.toThrow(
/not an agent card/,
await expect(
new A2aClient().fetchCard(CARD_URL, TOKEN),
).rejects.toMatchObject({
kind: 'invalid',
message: expect.stringMatching(/not an agent card/),
});
});

it('names the protocol version of a pre-1.0 card instead of calling it "not a card"', async () => {
// The shape most a2a-samples still serve: version at the top level, a
// single `url`, no supportedInterfaces.
fetchMock.mockResolvedValue(
respond({
json: {
protocolVersion: '0.3.0',
name: 'Legacy Agent',
description: 'x',
url: 'https://legacy.example/',
preferredTransport: 'JSONRPC',
version: '1.0.0',
capabilities: {},
defaultInputModes: ['text'],
defaultOutputModes: ['text'],
skills: [],
},
}),
);

await expect(
new A2aClient().fetchCard(CARD_URL, TOKEN),
).rejects.toMatchObject({
kind: 'version',
message: 'This agent speaks A2A 0.3.0; only 1.0 is supported',
});
});

it('refuses a card with no way to reach the agent', async () => {
Expand Down Expand Up @@ -181,7 +240,7 @@ describe('A2aClient.sendMessage', () => {
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(RPC_URL);
expect(init.method).toBe('POST');
expect(init.redirect).toBe('error');
expect(init.redirect).toBe('manual');
expect(init.headers).toMatchObject({
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
Expand All @@ -199,7 +258,7 @@ describe('A2aClient.sendMessage', () => {

await expect(
new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000),
).resolves.toEqual(task);
).resolves.toEqual({ task });
});

it('calls a revoked credential what it is', async () => {
Expand Down Expand Up @@ -231,6 +290,56 @@ describe('A2aClient.sendMessage', () => {
});
});

it('passes on the reason a peer gives in a JSON-RPC error sent with a 4xx status', async () => {
// Real public agents do this: the request arrived, the peer refused it and
// said why. That is an answer from the peer, not a network problem.
fetchMock.mockResolvedValue(
respond({
status: 400,
text: JSON.stringify({
jsonrpc: '2.0',
id: 1,
error: { code: -32602, message: 'Send a structured DataPart' },
}),
}),
);

await expect(
new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000),
).rejects.toMatchObject({
code: DelegationErrorCodes.Error,
message: 'Send a structured DataPart',
});
});

it('reports a redirecting peer as unreachable, with the target, and does not follow it', async () => {
fetchMock.mockResolvedValue(
respond({ status: 302, location: 'http://10.0.0.1/rpc' }),
);

await expect(
new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000),
).rejects.toMatchObject({
code: DelegationErrorCodes.Unreachable,
message:
'redirects to http://10.0.0.1/rpc, and redirects are not followed',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('keeps a 4xx without a JSON-RPC error as unreachable', async () => {
fetchMock.mockResolvedValue(
respond({ status: 404, text: '<html>Not Found</html>' }),
);

await expect(
new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000),
).rejects.toMatchObject({
code: DelegationErrorCodes.Unreachable,
message: expect.stringContaining('404'),
});
});

it('reports a network failure as unreachable', async () => {
fetchMock.mockRejectedValue(new Error('socket hang up'));

Expand All @@ -256,19 +365,40 @@ describe('A2aClient.sendMessage', () => {
});
});

it('refuses an answer that is not a task, having nothing to poll with', async () => {
fetchMock.mockResolvedValue(
respond({ json: { result: { message: { parts: [] } } } }),
);
it('accepts a plain message reply — the spec allows it and most public agents use it', async () => {
const message = {
messageId: 'm-reply',
role: 'ROLE_AGENT',
parts: [{ text: 'Hello from a message' }],
};
fetchMock.mockResolvedValue(respond({ json: { result: { message } } }));

await expect(
new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000),
).resolves.toEqual({ message });
});

it('refuses a result that is neither a task nor a message', async () => {
fetchMock.mockResolvedValue(respond({ json: { result: { hello: 1 } } }));

await expect(
new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000),
).rejects.toMatchObject({
code: DelegationErrorCodes.Error,
message: 'answered without a task',
message: 'answered with neither a task nor a message',
});
});

it('refuses a message without parts, which carries no reply at all', async () => {
fetchMock.mockResolvedValue(
respond({ json: { result: { message: { messageId: 'm' } } } }),
);

await expect(
new A2aClient().sendMessage(RPC_URL, TOKEN, params, 1000),
).rejects.toMatchObject({ code: DelegationErrorCodes.Error });
});

it('waits longer than the peer own limit, so its stated timeout wins', async () => {
fetchMock.mockResolvedValue(respond({ json: { result: { task } } }));
const spy = jest.spyOn(AbortSignal, 'timeout');
Expand Down
Loading
Loading