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
23 changes: 17 additions & 6 deletions lib/agents/middleware/hitl.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import { humanInTheLoopMiddleware as hitl } from "langchain"
import { hitlDecisionNoteInjectorMiddleware } from "./hitl-decision-note-injector.js"

function buildHitlInterruptMap(srv, tools = []) {
const ALLOWED_DECISIONS = ["approve", "reject", "edit"]

const isHitlAction = (action) => action?.["@agent.hitl"] ?? action?.["@Common.IsActionCritical"]

export function buildHitlInterruptMap(srv, tools = []) {
return tools.reduce((interruptOn, tool) => {
if (
srv.actions[tool.name]?.["@agent.hitl"] ??
srv.actions[tool.name]?.["@Common.IsActionCritical"]
) {
interruptOn[tool.name] = { allowedDecisions: ["approve", "reject", "edit"] }
// Per-action tool: its name IS the action name, so a static entry gates it.
if (isHitlAction(srv.actions[tool.name])) {
interruptOn[tool.name] = { allowedDecisions: ALLOWED_DECISIONS }
}
// Generic @cap-js/mcp "call" tool: one tool fronts every action, with the
// target action in args.action. The tool name matches no action, so gate
// per-call via `when`, which inspects the requested action at invoke time.
else if (tool.name === "call" && Object.values(srv.actions).some(isHitlAction)) {
interruptOn[tool.name] = {
allowedDecisions: ALLOWED_DECISIONS,
when: (request) => Boolean(isHitlAction(srv.actions[request.toolCall?.args?.action])),
}
}
return interruptOn
}, {})
Expand Down
73 changes: 73 additions & 0 deletions tests/integration/hitl-tool-wiring.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import cds from "@sap/cds"

// Boot the bookshop test app — CatalogService.submitOrder is annotated @agent.hitl,
// while getStock is a plain, non-gated function.
cds.test(import.meta.dirname + "/../projects/bookshop")

const { generateTools } = await import("../../srv/handlers/tools.js")
const { buildHitlInterruptMap, humanInTheLoopMiddleware } = await import(
"../../lib/agents/middleware/hitl.js"
)

// HITL is wired by matching tool calls against srv.actions[...]["@agent.hitl"].
// Per-action tools carry the action name directly; the generic combined "call"
// tool (the default) fronts every action behind one name and must gate per-call
// via `when`, reading the requested action from args.action.
describe("@agent.hitl tool wiring (non-hybrid)", () => {
const service = () => cds.services["CatalogService"]
const callArgs = (action) => ({ toolCall: { args: { action } } })

// Toggle generateTools' per-action vs generic decision without leaking env across tests.
const withPerActionTool = async (value, fn) => {
const prev = cds.env.mcp
cds.env.mcp = { ...prev, per_action_tool: value }
try {
return await fn()
} finally {
cds.env.mcp = prev
}
}

it("submitOrder carries the @agent.hitl annotation (model sanity)", () => {
expect(service().actions.submitOrder?.["@agent.hitl"]).toBeTruthy()
expect(service().actions.getStock?.["@agent.hitl"]).toBeFalsy()
})

it("installs HITL middleware for an @agent.hitl action under the default config", async () => {
const srv = service()
const middleware = await humanInTheLoopMiddleware(srv, generateTools(srv))
expect(middleware.length).toBeGreaterThan(0)
})

it("generic 'call' tool gates per-action via when (interrupts submitOrder, not getStock)", () => {
const srv = service()
const tools = generateTools(srv)
expect(tools.map((t) => t.name)).toContain("call")

const interruptOn = buildHitlInterruptMap(srv, tools)
// One entry — the combined tool — not a per-action key.
expect(Object.keys(interruptOn)).toEqual(["call"])
expect(interruptOn.call.when(callArgs("submitOrder"))).toBe(true)
expect(interruptOn.call.when(callArgs("getStock"))).toBe(false)
})

it("adds no 'call' entry (and no middleware) when no action is @agent.hitl", async () => {
// Synthetic service: has actions, but none annotated — the when-based entry
// must not be installed, so the whole HITL middleware stays off.
const srv = { actions: { getStock: {}, listBooks: {} } }
const tools = [{ name: "call" }]
expect(buildHitlInterruptMap(srv, tools)).toEqual({})
expect(await humanInTheLoopMiddleware(srv, tools)).toEqual([])
})

it("per-action tools gate by matching the action name directly", async () => {
const srv = service()
const interruptOn = await withPerActionTool(true, () => {
const tools = generateTools(srv)
expect(tools.map((t) => t.name)).toContain("submitOrder")
return buildHitlInterruptMap(srv, tools)
})
expect(interruptOn.submitOrder).toBeDefined()
expect(interruptOn.getStock).toBeUndefined()
})
})
Loading