Skip to content

Commit 9127525

Browse files
committed
fix: log Agent Control HTTP status and response body (#23)
1 parent 8459dbb commit 9127525

5 files changed

Lines changed: 315 additions & 11 deletions

File tree

src/agent-control-plugin.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { AgentControlClient } from "agent-control";
22
import type { JsonValue } from "agent-control";
33
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
4-
import { createPluginLogger, resolveLogLevel } from "./logging.ts";
4+
import { createPluginLogger, formatAgentControlError, resolveLogLevel } from "./logging.ts";
55
import {
66
buildControlExecutionEvents,
77
buildControlObservabilityIndex,
@@ -153,7 +153,7 @@ export default function register(api: OpenClawPluginApi) {
153153
.catch((err) => {
154154
gatewayWarmupStatus = "failed";
155155
logger.warn(
156-
`agent-control: gateway_boot_warmup failed duration_sec=${secondsSince(warmupStartedAt)} agent=${BOOT_WARMUP_AGENT_ID} error=${String(err)}`,
156+
`agent-control: gateway_boot_warmup failed duration_sec=${secondsSince(warmupStartedAt)} agent=${BOOT_WARMUP_AGENT_ID} error=${formatAgentControlError(err)}`,
157157
);
158158
});
159159

@@ -255,7 +255,7 @@ export default function register(api: OpenClawPluginApi) {
255255
);
256256
} catch (err) {
257257
logger.warn(
258-
`agent-control: unable to sync agent=${sourceAgentId} before tool evaluation: ${String(err)}`,
258+
`agent-control: unable to sync agent=${sourceAgentId} before tool evaluation: ${formatAgentControlError(err)}`,
259259
);
260260
if (failClosed) {
261261
logger.block(
@@ -348,7 +348,7 @@ export default function register(api: OpenClawPluginApi) {
348348
};
349349
} catch (err) {
350350
logger.warn(
351-
`agent-control: evaluation failed for agent=${sourceAgentId} tool=${event.toolName}: ${String(err)}`,
351+
`agent-control: evaluation failed for agent=${sourceAgentId} tool=${event.toolName}: ${formatAgentControlError(err)}`,
352352
);
353353
if (failClosed) {
354354
logger.block(

src/logging.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { asString } from "./shared.ts";
1+
import { asString, isRecord } from "./shared.ts";
22
import type { AgentControlPluginConfig, LogLevel, LoggerLike, PluginLogger } from "./types.ts";
33

44
const LOG_LEVELS: LogLevel[] = ["warn", "info", "debug"];
@@ -37,3 +37,123 @@ export function createPluginLogger(logger: LoggerLike, logLevel: LogLevel): Plug
3737
},
3838
};
3939
}
40+
41+
function safeJsonStringify(value: unknown): string | undefined {
42+
try {
43+
return JSON.stringify(value);
44+
} catch {
45+
return undefined;
46+
}
47+
}
48+
49+
function collectErrorChain(error: unknown): unknown[] {
50+
const chain: unknown[] = [];
51+
const seen = new WeakSet<object>();
52+
let current: unknown = error;
53+
54+
while (current !== undefined && current !== null) {
55+
chain.push(current);
56+
if (!isRecord(current) || !("cause" in current)) {
57+
break;
58+
}
59+
60+
const cause = current.cause;
61+
if (typeof cause === "object" && cause !== null) {
62+
if (seen.has(cause)) {
63+
break;
64+
}
65+
seen.add(cause);
66+
}
67+
current = cause;
68+
}
69+
70+
return chain;
71+
}
72+
73+
function statusCodeFromResponse(response: unknown): string | undefined {
74+
if (response instanceof Response) {
75+
return String(response.status);
76+
}
77+
if (!isRecord(response)) {
78+
return undefined;
79+
}
80+
81+
const status = response.status ?? response.statusCode;
82+
return typeof status === "number" || typeof status === "string" ? String(status) : undefined;
83+
}
84+
85+
function extractStatusCode(errorChain: unknown[]): string | undefined {
86+
for (const error of errorChain) {
87+
if (!isRecord(error)) {
88+
continue;
89+
}
90+
91+
const status = error.statusCode ?? error.status;
92+
if (typeof status === "number" || typeof status === "string") {
93+
return String(status);
94+
}
95+
96+
for (const key of ["response$", "response", "rawResponse"]) {
97+
const response = error[key];
98+
const responseStatus = statusCodeFromResponse(response);
99+
if (responseStatus) {
100+
return responseStatus;
101+
}
102+
}
103+
}
104+
105+
return undefined;
106+
}
107+
108+
function formatResponseBody(value: unknown): string | undefined {
109+
if (typeof value === "string") {
110+
return asString(value);
111+
}
112+
return safeJsonStringify(value);
113+
}
114+
115+
function extractResponseBody(errorChain: unknown[]): string | undefined {
116+
for (const error of errorChain) {
117+
if (!isRecord(error)) {
118+
continue;
119+
}
120+
121+
for (const key of ["body", "body$"]) {
122+
const body = formatResponseBody(error[key]);
123+
if (body) {
124+
return body;
125+
}
126+
}
127+
}
128+
129+
return undefined;
130+
}
131+
132+
function fallbackErrorMessage(error: unknown): string {
133+
if (error instanceof Error) {
134+
return error.message;
135+
}
136+
if (isRecord(error)) {
137+
const message = asString(error.message);
138+
if (message) {
139+
return message;
140+
}
141+
}
142+
return safeJsonStringify(error) ?? String(error);
143+
}
144+
145+
export function formatAgentControlError(error: unknown): string {
146+
const errorChain = collectErrorChain(error);
147+
const details: string[] = [];
148+
const status = extractStatusCode(errorChain);
149+
const responseBody = extractResponseBody(errorChain);
150+
151+
if (status) {
152+
details.push(`status=${status}`);
153+
}
154+
if (responseBody) {
155+
details.push(`response_body=${responseBody}`);
156+
}
157+
158+
return details.length > 0 ? details.join(" ") : fallbackErrorMessage(error);
159+
}

src/observability.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
ControlExecutionEvent,
88
EvaluationResponse,
99
} from "agent-control";
10+
import { formatAgentControlError } from "./logging.ts";
1011
import { isRecord } from "./shared.ts";
1112
import type { ControlObservabilityIdentity, PluginLogger } from "./types.ts";
1213

@@ -242,7 +243,7 @@ export function emitControlExecutionEvents(params: EmitControlExecutionEventsPar
242243
})
243244
.catch((error) => {
244245
params.logger.warn(
245-
`agent-control: observability_ingest failed agent=${params.agentName} step=${params.stepName} error=${String(error)}`,
246+
`agent-control: observability_ingest failed agent=${params.agentName} step=${params.stepName} error=${formatAgentControlError(error)}`,
246247
);
247248
});
248249
}

test/agent-control-plugin.test.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,24 @@ function createDeferred<T>() {
6363
return { promise, resolve, reject };
6464
}
6565

66+
function createAgentControlServerError(
67+
status: number,
68+
payload: Record<string, unknown>,
69+
statusText = "Server Error",
70+
) {
71+
const body = JSON.stringify(payload);
72+
return {
73+
name: "AgentControlSDKDefaultError",
74+
message: "API error occurred",
75+
response$: new Response(body, {
76+
status,
77+
statusText,
78+
headers: { "content-type": "application/json" },
79+
}),
80+
body$: body,
81+
};
82+
}
83+
6684
function createMockApi(pluginConfig: Record<string, unknown>): MockApi {
6785
const handlers = new Map<string, (...args: any[]) => unknown>();
6886
const info = vi.fn();
@@ -668,7 +686,9 @@ describe("agent-control plugin logging and blocking", () => {
668686
},
669687
],
670688
});
671-
clientMocks.ingestEvents.mockRejectedValueOnce(new Error("observability offline"));
689+
clientMocks.ingestEvents.mockRejectedValueOnce(
690+
createAgentControlServerError(503, { error: "observability offline" }, "Service Unavailable"),
691+
);
672692

673693
// When the plugin evaluates the tool call
674694
register(api.api);
@@ -678,9 +698,11 @@ describe("agent-control plugin logging and blocking", () => {
678698

679699
// Then the tool call is still allowed and the ingest failure is only logged
680700
expect(result).toBeUndefined();
681-
expect(api.warn).toHaveBeenCalledWith(
682-
expect.stringContaining("observability_ingest failed"),
683-
);
701+
const warning = api.warn.mock.calls
702+
.map(([message]) => String(message))
703+
.find((message) => message.includes("observability_ingest failed"));
704+
expect(warning).toContain("status=503");
705+
expect(warning).toContain('response_body={"error":"observability offline"}');
684706
});
685707

686708
it("does not emit control execution events when observability is explicitly disabled", async () => {
@@ -718,6 +740,31 @@ describe("agent-control plugin logging and blocking", () => {
718740
expect(clientMocks.ingestEvents).not.toHaveBeenCalled();
719741
});
720742

743+
744+
it("logs Agent Control response details when agent sync fails", async () => {
745+
// Given the Agent Control server rejects agent sync with an HTTP payload
746+
const api = createMockApi({
747+
serverUrl: "http://localhost:8000",
748+
});
749+
750+
clientMocks.agentsInit.mockRejectedValueOnce(
751+
createAgentControlServerError(409, { detail: "agent registration conflict" }, "Conflict"),
752+
);
753+
754+
// When the plugin attempts to sync before evaluating a tool call
755+
register(api.api);
756+
const result = await runBeforeToolCall(api);
757+
758+
// Then the failure warning includes the HTTP status and response payload
759+
expect(result).toBeUndefined();
760+
const warning = api.warn.mock.calls
761+
.map(([message]) => String(message))
762+
.find((message) => message.includes("unable to sync"));
763+
expect(warning).toContain("status=409");
764+
expect(warning).toContain('response_body={"detail":"agent registration conflict"}');
765+
expect(clientMocks.evaluationEvaluate).not.toHaveBeenCalled();
766+
});
767+
721768
it("allows the tool call when fail-open sync fails", async () => {
722769
// Given fail-open mode and a step-resolution failure before evaluation
723770
const api = createMockApi({
@@ -737,6 +784,30 @@ describe("agent-control plugin logging and blocking", () => {
737784
expect(api.warn).not.toHaveBeenCalledWith(expect.stringContaining("blocked tool=shell"));
738785
});
739786

787+
788+
it("logs Agent Control response details when evaluation fails", async () => {
789+
// Given the Agent Control server rejects policy evaluation with an HTTP payload
790+
const api = createMockApi({
791+
serverUrl: "http://localhost:8000",
792+
});
793+
794+
clientMocks.evaluationEvaluate.mockRejectedValueOnce(
795+
createAgentControlServerError(500, { error: "policy engine unavailable" }, "Internal Server Error"),
796+
);
797+
798+
// When the plugin attempts to evaluate a tool call
799+
register(api.api);
800+
const result = await runBeforeToolCall(api);
801+
802+
// Then the failure warning includes the HTTP status and response payload
803+
expect(result).toBeUndefined();
804+
const warning = api.warn.mock.calls
805+
.map(([message]) => String(message))
806+
.find((message) => message.includes("evaluation failed for agent=default tool=shell"));
807+
expect(warning).toContain("status=500");
808+
expect(warning).toContain('response_body={"error":"policy engine unavailable"}');
809+
});
810+
740811
it("blocks the tool call when fail-closed evaluation throws", async () => {
741812
// Given fail-closed mode and an evaluation request that throws
742813
const api = createMockApi({

0 commit comments

Comments
 (0)