@cap-js/agents 0.9.6 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +2 -131
  2. package/_i18n/messages.properties +2 -0
  3. package/_i18n/messages_ar.properties +48 -0
  4. package/_i18n/messages_bg.properties +48 -0
  5. package/_i18n/messages_cs.properties +48 -0
  6. package/_i18n/messages_da.properties +48 -0
  7. package/_i18n/messages_de.properties +48 -0
  8. package/_i18n/messages_el.properties +48 -0
  9. package/_i18n/messages_en.properties +48 -0
  10. package/_i18n/messages_es.properties +48 -0
  11. package/_i18n/messages_es_MX.properties +48 -0
  12. package/_i18n/messages_fi.properties +48 -0
  13. package/_i18n/messages_fr.properties +48 -0
  14. package/_i18n/messages_he.properties +48 -0
  15. package/_i18n/messages_hr.properties +48 -0
  16. package/_i18n/messages_hu.properties +48 -0
  17. package/_i18n/messages_it.properties +48 -0
  18. package/_i18n/messages_ja.properties +48 -0
  19. package/_i18n/messages_kk.properties +48 -0
  20. package/_i18n/messages_ko.properties +48 -0
  21. package/_i18n/messages_ms.properties +48 -0
  22. package/_i18n/messages_nl.properties +48 -0
  23. package/_i18n/messages_no.properties +48 -0
  24. package/_i18n/messages_pl.properties +48 -0
  25. package/_i18n/messages_pt.properties +48 -0
  26. package/_i18n/messages_ro.properties +48 -0
  27. package/_i18n/messages_ru.properties +48 -0
  28. package/_i18n/messages_sh.properties +48 -0
  29. package/_i18n/messages_sk.properties +48 -0
  30. package/_i18n/messages_sl.properties +48 -0
  31. package/_i18n/messages_sv.properties +48 -0
  32. package/_i18n/messages_th.properties +48 -0
  33. package/_i18n/messages_tr.properties +48 -0
  34. package/_i18n/messages_uk.properties +48 -0
  35. package/_i18n/messages_vi.properties +48 -0
  36. package/_i18n/messages_zh_CN.properties +48 -0
  37. package/_i18n/messages_zh_TW.properties +48 -0
  38. package/cds-plugin.js +15 -10
  39. package/lib/agents/middleware/content-filter.js +4 -0
  40. package/lib/agents/middleware/hitl-decision-note-injector.js +18 -6
  41. package/lib/agents/middleware/hitl.js +17 -6
  42. package/lib/agents/middleware/index.js +3 -1
  43. package/lib/agents/middleware/masking.js +127 -0
  44. package/lib/agents/middleware/remote-mcp.js +10 -5
  45. package/lib/agents/middleware/status-update.js +209 -76
  46. package/lib/agents/middleware/tool-wrap.js +1 -2
  47. package/lib/agents/summarize-on-timeout.js +5 -2
  48. package/lib/config/local.js +121 -8
  49. package/lib/eval/eval-run.js +50 -4
  50. package/lib/masking/index.js +90 -0
  51. package/lib/masking/store.js +80 -0
  52. package/lib/masking/structured/findElements.js +459 -0
  53. package/lib/masking/structured/index.js +170 -0
  54. package/lib/masking/unstructured/dpi.js +84 -0
  55. package/lib/masking/unstructured/hana.js +72 -0
  56. package/lib/masking/unstructured/index.js +52 -0
  57. package/lib/models/aicore.js +16 -3
  58. package/lib/models/openai.js +9 -0
  59. package/lib/preview/chat.html +623 -119
  60. package/lib/protocol/persistence/cleanup.js +13 -4
  61. package/lib/telemetry/chat-tracing.js +9 -2
  62. package/lib/telemetry/mlflow/evaluation.js +83 -4
  63. package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +39 -5
  64. package/lib/telemetry/mlflow/exporter/MlflowExporter.js +43 -4
  65. package/lib/telemetry/mlflow/index.js +1 -7
  66. package/lib/telemetry/mlflow/prompts.js +15 -11
  67. package/lib/telemetry/mlflow/tracing.js +3 -3
  68. package/lib/telemetry/span-masking.js +165 -0
  69. package/lib/telemetry/tool-tracing.js +4 -4
  70. package/lib/utils/markdown.js +3 -10
  71. package/lib/utils/resilience.js +2 -1
  72. package/lib/utils/toml.js +67 -0
  73. package/lib/utils/usage.js +34 -0
  74. package/lib/utils/utils.js +43 -1
  75. package/package.json +33 -26
  76. package/srv/handlers/graph-executor/crash-handler.js +47 -0
  77. package/srv/handlers/graph-executor/hitl.js +27 -0
  78. package/srv/handlers/graph-executor.js +69 -20
  79. package/srv/handlers/index.js +6 -1
  80. package/srv/handlers/mcp-tools.js +11 -2
  81. package/srv/handlers/subagent-tools.js +13 -4
  82. package/srv/handlers/system-prompt.js +30 -20
  83. package/srv/handlers/tools.js +14 -13
@@ -0,0 +1,48 @@
1
+ # Agent status messages (shown during task execution)
2
+ #XMSG Status info message send to the user client when a tool is called. {0} = tool name
3
+ agent_status_calling_tools=正在呼叫 {0}
4
+ #XMSG Status info message send to the user client when an entity is being queried. {0} = entity name
5
+ agent_status_querying=正在查詢 {0}
6
+ #XMSG Status info message send to the user client after a tool result returned and the agent is processing it
7
+ agent_status_processing_response=正在處理工具回應
8
+ #XMSG Status info message send to the user client after multiple tool results returned and the agent is processing it
9
+ agent_status_processing_responses=正在處理工具回應
10
+ agent_status_summarizing_progress=已中斷代理程式;正在彙總進度。
11
+ AGENT_SUMMARY_QUOTA_FALLBACK=由於工作細項已到達資源耗用限制,因此已停止。可能已完成部份工作 - 請查看結果或使用較簡單的請求重試。
12
+ AGENT_SUMMARY_TIMEOUT_FALLBACK=由於工作細項已到達時間限制,因此已停止。進度可能不完整,要繼續執行或停止?
13
+
14
+ # Authentication
15
+ #XMSG Error message for HTTP 401 status
16
+ UNAUTHORIZED=未授權
17
+ #XMSG Error message for HTTP 403 status
18
+ FORBIDDEN=已禁止
19
+
20
+ # Input validation
21
+ #XMSG Error message send to the users client when their message exceeds the limit. {0} = number of allowed characters
22
+ MESSAGE_TOO_LONG=訊息不可超過 {0} 個字元。
23
+
24
+ # Quota enforcement
25
+ QUOTA_CONCURRENT_TASKS=已達到 {0} 個同時工作細項上限,請稍後再試一次。
26
+ QUOTA_CONCURRENT_TASKS_PER_USER=已達到每位使用者 {0} 個同時工作細項上限,請稍後再試一次。
27
+ QUOTA_TASKS_PER_HOUR=此租用戶已達到每小時 {0} 個工作細項上限,請稍後再試一次。
28
+ QUOTA_TASKS_PER_HOUR_PER_USER=已達到每位使用者每小時 {0} 個工作細項上限,請稍後再試一次。
29
+ QUOTA_TOOL_CALLS_PER_HOUR=已使用每小時的工具呼叫數量上限 ({0}),請稍後再試一次。
30
+ QUOTA_LLM_TOKENS_PER_DAY=已使用今天的 LLM 權杖數量上限 ({0}),請明天再試一次。
31
+
32
+ # Content filter
33
+ CONTENT_FILTER_BLOCKED=內容安全篩選已凍結您的輸入,請重新撰寫。
34
+ CONTENT_FILTER_UNAVAILABLE=內容安全檢查暫時無法使用,請再試一次。
35
+ CONTENT_FILTER_TOOL_BLOCKED=上次呼叫的工具產生惡意結果,請嘗試不同方向以避免相同惡意資料。
36
+
37
+ # Push notifications
38
+ PUSH_URL_NOT_ALLOWED=推送通知回呼 URL ''{0}'' 不在允許的網域中:{1}
39
+
40
+ # HITL
41
+ HITL_APPROVE=核准
42
+ HITL_REJECT=拒絕
43
+ HITL_CONTINUE=繼續
44
+ HITL_STOP=停止
45
+ RESUME_REQUIRES_TEXT=繼續訊息必須包含文字 (例如:''核准'' 或 ''拒絕'') 或資料部份。
46
+
47
+ # LLM
48
+ LLM_UNAVAILABLE=代理程式暫時無法使用,請稍後再試一次。
package/cds-plugin.js CHANGED
@@ -69,24 +69,27 @@ cds.on("bootstrap", (app) => {
69
69
 
70
70
  !(function cds_requires_llm() {
71
71
  cds.on("served", async () => {
72
- if (
73
- cds.requires.llm === "anthropic" ||
74
- cds.requires.llm?.kind === "anthropic" ||
75
- cds.requires.llm === "auto" ||
76
- cds.requires.llm?.kind === "auto"
77
- ) {
72
+ const kind = cds.requires.llm?.kind ?? cds.requires.llm
73
+ if (kind === "auto") {
78
74
  const { resolve_config } = await import("./lib/config/local.js")
79
- let options = cds.requires.llm
80
- if (options === "auto") options = { kind: "auto" }
81
- cds.env.requires.llm = resolve_config(options)
75
+ cds.env.requires.llm = resolve_config({ kind: "auto" })
76
+ }
77
+ if (kind === "anthropic") {
78
+ const { resolve_anthropic_config } = await import("./lib/config/local.js")
79
+ cds.env.requires.llm = resolve_anthropic_config(cds.requires.llm)
80
+ }
81
+ if (kind === "openai") {
82
+ const { resolve_openai_config } = await import("./lib/config/local.js")
83
+ cds.env.requires.llm = resolve_openai_config(cds.requires.llm)
82
84
  }
83
85
 
84
86
  const config = cds.requires.llm,
85
87
  credentials = {}
86
- const { url, destination, anthropicApiUrl, apiKey } = config?.credentials || {}
88
+ const { url, destination, anthropicApiUrl, baseURL, apiKey } = config?.credentials || {}
87
89
  if (url) credentials.url = url
88
90
  if (destination) credentials.destination = destination
89
91
  if (anthropicApiUrl) credentials.anthropicApiUrl = anthropicApiUrl
92
+ if (baseURL) credentials.baseURL = baseURL
90
93
  if (apiKey) credentials.apiKey = "***"
91
94
  LOG.info(`cds.connect.to 'llm' with:`, { ...config, credentials })
92
95
  })
@@ -103,7 +106,9 @@ if (cds.env.requires?.telemetry)
103
106
  // Schedule active_users metric computation + MLflow exporter
104
107
  cds.on("served", async () => {
105
108
  const { setupActiveUsersMetric } = await import("./lib/telemetry/active-users.js")
109
+ const { setupTraceScrubbing } = await import("./lib/telemetry/span-masking.js")
106
110
  setupActiveUsersMetric()
111
+ await setupTraceScrubbing()
107
112
  // Defer LangChain patching so the CDS model is fully loaded before patches land.
108
113
  // opt-out via cds.env.agents.trace_langchain = false
109
114
  if (cds.env.agents?.trace_langchain !== false) {
@@ -47,6 +47,7 @@ export async function contentFilterMiddleware(model) {
47
47
  },
48
48
  {
49
49
  disableStreaming: true,
50
+ maxRetries: 0,
50
51
  customRequestConfig: {
51
52
  middleware: [timeout(10_000), retry(2)],
52
53
  },
@@ -112,6 +113,9 @@ export async function contentFilterMiddleware(model) {
112
113
  try {
113
114
  await inputFilterModel.invoke(filterMessages)
114
115
  } catch (err) {
116
+ if (err.rootCause?.message.match(/Could not find service credentials for AI Core/)) {
117
+ throw err.rootCause
118
+ }
115
119
  const { status, data } = extractErrorDetails(err)
116
120
  const isFilterBlock =
117
121
  /Filtering Module/i.test(data?.error?.location || "") && status === 400
@@ -2,17 +2,29 @@ import { createMiddleware } from "langchain"
2
2
  import { HumanMessage } from "@langchain/core/messages"
3
3
  import { z } from "zod"
4
4
 
5
- // Injects user HITL decisions before the model continues after a rejection or edit.
5
+ // Injects user HITL decisions (edit/reject) as a HumanMessage before the model
6
+ // continues, so the model treats them as deliberate user decisions rather than
7
+ // tool failures.
8
+ //
9
+ // LangChain's humanInTheLoop middleware routes these decisions differently:
10
+ // - edit → jumpTo undefined → normal flow → `beforeModel` runs. Inject + clear here.
11
+ // - reject → jumpTo "model" → re-enters the model node directly, SKIPPING all
12
+ // `beforeModel` hooks. `wrapModelCall` still runs, so it injects as a
13
+ // fallback when `beforeModel` did not consume the note.
14
+ //
15
+ // So we use `wrapModel` to inject the message and clear the note after the model call.
6
16
  export function hitlDecisionNoteInjectorMiddleware() {
7
17
  return createMiddleware({
8
18
  name: "hitlDecisionNoteInjectorMiddleware",
9
19
  stateSchema: z.object({ _hitlDecisionNote: z.string().optional() }),
10
- beforeModel: async (state) => {
20
+ wrapModelCall: async (request, handler) => {
21
+ const note = request.state?._hitlDecisionNote
22
+ if (!note) return handler(request)
23
+ return handler({ ...request, messages: [...(request.messages || []), new HumanMessage(note)] })
24
+ },
25
+ afterModel: async (state) => {
11
26
  if (!state._hitlDecisionNote) return
12
- return {
13
- messages: [new HumanMessage(state._hitlDecisionNote)],
14
- _hitlDecisionNote: undefined,
15
- }
27
+ return { _hitlDecisionNote: undefined }
16
28
  },
17
29
  })
18
30
  }
@@ -1,13 +1,24 @@
1
1
  import { humanInTheLoopMiddleware as hitl } from "langchain"
2
2
  import { hitlDecisionNoteInjectorMiddleware } from "./hitl-decision-note-injector.js"
3
3
 
4
- function buildHitlInterruptMap(srv, tools = []) {
4
+ const ALLOWED_DECISIONS = ["approve", "reject", "edit"]
5
+
6
+ const isHitlAction = (action) => action?.["@agent.hitl"] ?? action?.["@Common.IsActionCritical"]
7
+
8
+ export function buildHitlInterruptMap(srv, tools = []) {
5
9
  return tools.reduce((interruptOn, tool) => {
6
- if (
7
- srv.actions[tool.name]?.["@agent.hitl"] ??
8
- srv.actions[tool.name]?.["@Common.IsActionCritical"]
9
- ) {
10
- interruptOn[tool.name] = { allowedDecisions: ["approve", "reject", "edit"] }
10
+ // Per-action tool: its name IS the action name, so a static entry gates it.
11
+ if (isHitlAction(srv.actions[tool.name])) {
12
+ interruptOn[tool.name] = { allowedDecisions: ALLOWED_DECISIONS }
13
+ }
14
+ // Generic @cap-js/mcp "call" tool: one tool fronts every action, with the
15
+ // target action in args.action. The tool name matches no action, so gate
16
+ // per-call via `when`, which inspects the requested action at invoke time.
17
+ else if (tool.name === "call" && Object.values(srv.actions).some(isHitlAction)) {
18
+ interruptOn[tool.name] = {
19
+ allowedDecisions: ALLOWED_DECISIONS,
20
+ when: (request) => Boolean(isHitlAction(srv.actions[request.toolCall?.args?.action])),
21
+ }
11
22
  }
12
23
  return interruptOn
13
24
  }, {})
@@ -1,5 +1,6 @@
1
1
  export default async function buildMiddleware(srv, options = {}) {
2
2
  const { tools, model } = options
3
+ const { masking } = await import("./masking.js")
3
4
  const { quotaEnforcerMiddleware } = await import("./quota-enforcer.js")
4
5
  const { contentFilterMiddleware } = await import("./content-filter.js")
5
6
  const { agentActionsMiddleware } = await import("./agent-actions.js")
@@ -18,11 +19,12 @@ export default async function buildMiddleware(srv, options = {}) {
18
19
 
19
20
  return [
20
21
  ...dynamicMcpMiddlewares,
22
+ masking(srv), // first — all downstream middleware see pseudonymized data
21
23
  ...(await quotaEnforcerMiddleware()),
22
24
  await contentFilterMiddleware(model),
23
25
  await agentActionsMiddleware(),
24
26
  patchToolCallsMiddleware(),
25
- await statusUpdateMiddleware(),
27
+ await statusUpdateMiddleware(tools),
26
28
  ...(await humanInTheLoopMiddleware(srv, tools)),
27
29
  toolSelectionMiddleware(),
28
30
  toolWrapMiddleware(srv),
@@ -0,0 +1,127 @@
1
+ import cds from "@sap/cds"
2
+ import { createMiddleware, ToolMessage } from "langchain"
3
+ import { randomBytes } from "node:crypto"
4
+ import { decode, encode } from "@toon-format/toon"
5
+ import { ensureSession, pseudonymize, resolveRemoteMcpTool } from "../../masking/index.js"
6
+ import { resolveArgs, needsMasking } from "../../masking/structured/index.js"
7
+ import z from "zod"
8
+ import { Command, StateSchema, ReducedValue, isCommand } from "@langchain/langgraph"
9
+
10
+ // REVISIT: Whether it is possible to make it easier without the ReducedValue class
11
+ const stateSchema = new StateSchema({
12
+ seed: new ReducedValue(z.string().default(randomBytes(16).toString("hex")), {
13
+ reducer: (_current, next) => next,
14
+ }),
15
+ hashToOriginal: new ReducedValue(z.map(z.string(), z.string()).default(new Map()), {
16
+ reducer: (_current, next) => next,
17
+ }),
18
+ })
19
+
20
+ export function masking(srv) {
21
+ if (!cds.env.agents?.masking) return null
22
+
23
+ return createMiddleware({
24
+ name: "masking",
25
+ stateSchema,
26
+
27
+ beforeAgent: async () => {
28
+ if (!needsMasking(srv.name)) return
29
+ await ensureSession()
30
+ cds.context["agent.masking.tools"] = true
31
+ },
32
+
33
+ wrapToolCall: async (request, handler) => {
34
+ const session = cds.context?.["agent.pseudonyms"]
35
+ if (!session || !cds.context["agent.masking.tools"]) return handler(request)
36
+
37
+ const toolName = request.toolCall?.name
38
+ const toolArgs = request.toolCall?.args ?? {}
39
+ const resolvedArgs = resolveArgs(toolArgs, session)
40
+ const resolvedRequest =
41
+ resolvedArgs !== toolArgs
42
+ ? { ...request, toolCall: { ...request.toolCall, args: resolvedArgs } }
43
+ : request
44
+
45
+ const result = await handler(resolvedRequest)
46
+ const isCmd = isCommand(result)
47
+ const toolMessage = isCmd ? result.update?.messages?.[0] : result
48
+
49
+ let outMessage = toolMessage
50
+ if (toolMessage?.content) {
51
+ // Resolve remote MCP tool prefix before srv.send (cds.context differs inside event)
52
+ const remote = resolveRemoteMcpTool(toolName)
53
+ const effectiveToolName = remote?.bareName ?? toolName
54
+
55
+ // Decode TOON or JSON — pseudonymize operates on decoded objects
56
+ const { decoded, format } = decodeContent(toolMessage.content)
57
+ if (decoded && typeof decoded !== "string") {
58
+ const result = await pseudonymize(
59
+ {
60
+ data: decoded,
61
+ type: toolArgs.cql ? "cql" : "action",
62
+ seed: session._seed,
63
+ metadata: {
64
+ cql: toolArgs.cql,
65
+ toolName: effectiveToolName,
66
+ ...(remote?.remoteSrv && { serviceName: remote.remoteSrv.name }),
67
+ },
68
+ },
69
+ srv,
70
+ )
71
+
72
+ session.addMappings(result?.mappings)
73
+
74
+ const encoded =
75
+ format === "toon"
76
+ ? encode(result.data)
77
+ : format === "json"
78
+ ? JSON.stringify(result.data)
79
+ : result.data
80
+ if (encoded !== toolMessage.content) {
81
+ outMessage = new ToolMessage({ ...toolMessage, content: encoded })
82
+ }
83
+ } else {
84
+ // Plain string (e.g. read_file output) or unparseable — scrub using session mappings.
85
+ const scrubbed = session.scrubText(toolMessage.content)
86
+ if (scrubbed !== toolMessage.content) {
87
+ outMessage = new ToolMessage({ ...toolMessage, content: scrubbed })
88
+ }
89
+ }
90
+ }
91
+
92
+ const cmd = new Command({
93
+ update: {
94
+ messages: [outMessage],
95
+ seed: session._seed,
96
+ hashToOriginal: session._hashToOriginal,
97
+ },
98
+ })
99
+ if (isCmd) return merge(result, cmd)
100
+ return cmd
101
+ },
102
+ })
103
+ }
104
+
105
+ function merge(c1, c2) {
106
+ return new Command({
107
+ ...c1,
108
+ ...c2,
109
+ update: {
110
+ ...c1.update,
111
+ ...c2.update,
112
+ },
113
+ })
114
+ }
115
+
116
+ function decodeContent(content) {
117
+ if (typeof content !== "string" || !content) return {}
118
+ try {
119
+ return { decoded: decode(content), format: "toon" }
120
+ } catch {
121
+ try {
122
+ return { decoded: JSON.parse(content), format: "json" }
123
+ } catch {
124
+ return {}
125
+ }
126
+ }
127
+ }
@@ -2,7 +2,7 @@ import cds from "@sap/cds"
2
2
  import { createMiddleware } from "langchain"
3
3
  import { ToolMessage } from "@langchain/core/messages"
4
4
  import { MultiServerMCPClient } from "@langchain/mcp-adapters"
5
- import { toolName } from "../../utils/utils.js"
5
+ import { toolName, mcpToolKind } from "../../utils/utils.js"
6
6
 
7
7
  const LOG = cds.log("agents:mcp")
8
8
 
@@ -27,20 +27,25 @@ export function remoteMcpMiddleware() {
27
27
  })
28
28
  let raw = await client.getTools()
29
29
  if (toolFilter?.length) raw = raw.filter((t) => toolFilter.includes(t.name))
30
- for (const t of raw) t.name = toolName(`${serviceName}_${t.name}`)
31
- cache[mcpUrl] = raw
30
+ for (const t of raw) {
31
+ t.metadata = { ...t.metadata, serviceName, kind: mcpToolKind(t.name) }
32
+ t.name = toolName(`${serviceName}_${t.name}`)
33
+ }
34
+ cache[mcpUrl] = { serviceName, tools: raw }
32
35
  LOG.debug(
33
36
  `Got ${raw.length} MCP tools from ${mcpUrl}: ${raw.map((t) => t.name).join(", ")}`,
34
37
  )
35
38
  }),
36
39
  )
37
- const resolved = placeholders.flatMap(({ mcpUrl }) => cache[mcpUrl] ?? [])
40
+ const resolved = placeholders.flatMap(({ mcpUrl }) => cache[mcpUrl]?.tools ?? [])
38
41
  const staticTools = (request.tools ?? []).filter((t) => !t._mcpDynamic)
39
42
  return handler({ ...request, tools: [...staticTools, ...resolved] })
40
43
  },
41
44
 
42
45
  wrapToolCall: async (request, handler) => {
43
- const allCached = Object.values(cds.context.__mcpDynamicTools ?? {}).flat()
46
+ const allCached = Object.values(cds.context.__mcpDynamicTools ?? {}).flatMap(
47
+ (e) => e.tools ?? [],
48
+ )
44
49
  const tool = allCached.find((t) => t.name === request.toolCall.name)
45
50
  if (!tool) return handler(request)
46
51
  try {