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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- `cds.agents.retention` (default 30d) to configure retention of Tasks and related assets stored for A2A and the agent
- Outgoing MCP and A2A connections now consider `credentials.path` together with the destination
- Added additional OpenTelemetry span attributes detailing how many content filters were active
- Whether a tool succeeded or failed is now written as a debug log. This is for example helpful for investigating whether skills were loaded or not.

### Fixed

Expand Down
2 changes: 2 additions & 0 deletions lib/agents/middleware/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default async function buildMiddleware(srv, options = {}) {
const { statusUpdateMiddleware } = await import("./status-update.js")
const { humanInTheLoopMiddleware } = await import("./hitl.js")
const { toolSelectionMiddleware } = await import("./tool-selection.js")
const { toolWrapMiddleware } = await import("./tool-wrap.js")
return [
...(await quotaEnforcerMiddleware()),
await contentFilterMiddleware(model),
Expand All @@ -15,5 +16,6 @@ export default async function buildMiddleware(srv, options = {}) {
await statusUpdateMiddleware(),
...(await humanInTheLoopMiddleware(srv, tools)),
toolSelectionMiddleware(),
toolWrapMiddleware(),
].filter(Boolean)
}
43 changes: 43 additions & 0 deletions lib/agents/middleware/tool-wrap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createMiddleware } from "langchain"
import { ToolMessage } from "@langchain/core/messages"
import { isGraphInterrupt } from "@langchain/langgraph"
import cds from "@sap/cds"

const LOG = cds.log("agents")

/**
* Converts tool errors into error ToolMessages so the LLM can retry.
* Handles two paths: thrown errors (err.details appended when present)
* and tools returning artifact.isError=true (@cap-js/mcp action pattern).
*/
export function toolWrapMiddleware() {
return createMiddleware({
name: "ToolWrapMiddleware",
wrapToolCall: async (request, handler) => {
const { name, id } = request.toolCall
try {
const result = await handler(request)
if (ToolMessage.isInstance(result) && result.artifact?.isError === true) {
result.status = "error"
}
if (result?.status === "error") LOG.debug("[tool] error", name, result.content)
else LOG.debug("[tool] completed", name)
return result
} catch (err) {
if (isGraphInterrupt(err)) throw err
LOG.debug("[tool] error", name, err)
let content = `Error: ${err.message}`
if (Array.isArray(err.details) && err.details.length > 0) {
const lines = err.details.map((d) => `- ${d.message}`).join("\n")
content += `\n${lines}`
}
return new ToolMessage({
content,
tool_call_id: id ?? "",
name,
status: "error",
})
}
},
})
}
7 changes: 1 addition & 6 deletions lib/telemetry/tool-tracing.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,7 @@ export function _patchToolsProto(proto) {
})
}

// Swallow error: return as string so LLM can see it and retry.
// Deep agents use ToolNode with handleToolErrors (default: true) which
// already wraps errors as ToolMessages. Custom graphs that invoke tools
// directly also benefit from error swallowing — the graph continues and
// the LLM can retry with a corrected query.
return `Error: ${err.message}`
throw err
} finally {
if (span) span.end()
}
Expand Down
13 changes: 13 additions & 0 deletions tests/integration/__snapshots__/agent-card.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ See https://cap.cloud.sap/docs/cds/common#entity-currencies
"function",
],
},
{
"description": "Validate an order (always fails with two errors for testing)",
"examples": [
"Validate an order (always fails with two errors for testing)",
],
"id": "validateOrder",
"name": "validateOrder",
"tags": [
"validateorder",
"action",
"hitl",
],
},
],
"supportedInterfaces": [
{
Expand Down
19 changes: 19 additions & 0 deletions tests/integration/error-sanitization.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,22 @@ describe("@cap-js/agents - Production error sanitization", () => {
})
})
})

describe("@cap-js/agents - toolWrapMiddleware error handling", () => {
it("includes err.details from a CAP multi-error action in the ToolMessage content", async () => {
const { ToolMessage } = await import("@langchain/core/messages")
const { toolWrapMiddleware } = await import("../../lib/agents/middleware/tool-wrap.js")

const srv = cds.services.CatalogService
const mw = toolWrapMiddleware()
const result = await mw.wrapToolCall(
{ toolCall: { name: "validateOrder", id: "test-call-1" } },
async () => srv.send("validateOrder", { book: 1, quantity: 1 }),
)

expect(ToolMessage.isInstance(result)).toBe(true)
expect(result.status).toBe("error")
expect(result.content).toContain("book is required")
expect(result.content).toContain("quantity must be positive")
})
})
4 changes: 1 addition & 3 deletions tests/integration/telemetry-v1.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,7 @@ describe("@cap-js/agents - OTEL v1 backward compatibility (@cap-js/telemetry ^1)
const exporter = await getSpanExporter()
exporter.reset()

// Tool errors are swallowed and returned as strings (so LLM can retry)
const result = await failingTool.invoke({})
expect(result).toMatch(/intentional failure/)
await expect(failingTool.invoke({})).rejects.toThrow(/intentional failure/)

const { trace } = await import("@opentelemetry/api")
const delegate = trace.getTracerProvider().getDelegate?.() || trace.getTracerProvider()
Expand Down
8 changes: 8 additions & 0 deletions tests/projects/bookshop/srv/cat-service.cds
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,12 @@ service CatalogService {
*/
@description: 'Get stock level for a specific book'
function getStock( @description: 'The book ID' book: Books:ID @mandatory ) returns Integer;

/**
* Validate an order — always rejects with two field-level errors.
* Used to test that err.details from multi-error CAP responses are
* forwarded to the LLM via toolWrapMiddleware.
*/
@description: 'Validate an order (always fails with two errors for testing)'
action validateOrder(book: Books:ID, quantity: Integer) returns {};
}
6 changes: 6 additions & 0 deletions tests/projects/bookshop/srv/cat-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export default class CatalogService extends cds.ApplicationService {
return book.stock
})

this.before("validateOrder", (req) => {
req.error(400, "book is required", "book")
req.error(400, "quantity must be positive", "quantity")
})
this.on("validateOrder", () => {})

return super.init()
}
}