@cap-js/agents 0.9.4 → 0.9.5

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.
@@ -71,9 +71,9 @@ export function syncSystemPrompt(messages) {
71
71
  export function resolvePromptName(srv) {
72
72
  if (!srv?.name) return ""
73
73
  const agentDir = resolveAgentDir(srv)
74
- if (!agentDir) return srv.name
74
+ if (!agentDir) return _mlflowName(srv.name)
75
75
  const root = cds.root ?? process.cwd()
76
- return relative(root, join(agentDir, "AGENTS.md")).replace(/\\/g, "/")
76
+ return _mlflowName(relative(root, join(agentDir, "AGENTS.md")).replace(/\\/g, "/"))
77
77
  }
78
78
 
79
79
  export function hashPrompt(text) {
@@ -88,6 +88,10 @@ function _description(name) {
88
88
  return `System prompt for CAP agent service "${name.split("/")[0]}"`
89
89
  }
90
90
 
91
+ function _mlflowName(name) {
92
+ return String(name).replace(/[\\/.:]/g, "_")
93
+ }
94
+
91
95
  function _registrationTags() {
92
96
  return [{ key: "mlflow.prompt.is_prompt", value: "true" }]
93
97
  }
@@ -0,0 +1,132 @@
1
+ import cds from "@sap/cds"
2
+
3
+ export const PROMPT_CACHE_MODEL_PARAMS = Symbol("promptCacheModelParams")
4
+
5
+ const CACHE_CONTROL_EPHEMERAL = { type: "ephemeral" }
6
+
7
+ export function isPromptCachingModel(model) {
8
+ return promptCachingMode(model) !== undefined
9
+ }
10
+
11
+ export function withPromptCachingParams(model, params) {
12
+ if (params !== undefined && (typeof params !== "object" || Array.isArray(params))) return params
13
+ const mode = promptCachingMode(model)
14
+ const base = params || {}
15
+ if (mode === "gpt-implicit") {
16
+ return {
17
+ ...base,
18
+ ...(base.prompt_cache_options === undefined
19
+ ? { prompt_cache_options: { mode: "implicit", ttl: "30m" } }
20
+ : {}),
21
+ }
22
+ }
23
+ if (mode === "gpt-retention") {
24
+ return {
25
+ ...base,
26
+ ...(base.prompt_cache_retention === undefined ? { prompt_cache_retention: "24h" } : {}),
27
+ }
28
+ }
29
+ return params
30
+ }
31
+
32
+ export function withPromptCachingMessages(model, messages, opts) {
33
+ if (promptCachingMode(model) !== "claude") return { messages, opts }
34
+
35
+ const cachedMessages = [...messages]
36
+ addCacheControlToLast(cachedMessages, "system")
37
+ addCacheControlToLast(cachedMessages, "ai")
38
+ addCacheControlToLast(cachedMessages, "human")
39
+
40
+ if (!opts?.tools?.length) return { messages: cachedMessages, opts }
41
+ const tools = [...opts.tools]
42
+ tools[tools.length - 1] = { ...tools[tools.length - 1], cache_control: CACHE_CONTROL_EPHEMERAL }
43
+ return { messages: cachedMessages, opts: { ...opts, tools } }
44
+ }
45
+
46
+ export function withPromptCachingOptions(model, opts) {
47
+ const mode = promptCachingMode(model)
48
+ if (mode === "nova" && !opts?.cache_control) {
49
+ return { ...opts, cache_control: CACHE_CONTROL_EPHEMERAL }
50
+ }
51
+ if (!isGptCachingMode(mode)) return opts
52
+
53
+ const params = opts?.[PROMPT_CACHE_MODEL_PARAMS] || {}
54
+ if (params.prompt_cache_key !== undefined) return opts
55
+ return {
56
+ ...opts,
57
+ [PROMPT_CACHE_MODEL_PARAMS]: {
58
+ ...params,
59
+ prompt_cache_key: opts?.prompt_cache_key ?? buildPromptCacheKey(),
60
+ },
61
+ }
62
+ }
63
+
64
+ function buildPromptCacheKey() {
65
+ return cds.context?.tenant || ""
66
+ }
67
+
68
+ function promptCachingMode(model) {
69
+ const name = String(model || "")
70
+ if (/anthropic|claude/i.test(name)) return "claude"
71
+ if (/(?:^|[-_])nova(?:[-_]|$)/i.test(name) || /amazon.*nova/i.test(name)) return "nova"
72
+
73
+ const version = gptVersion(name)
74
+ if (!version) return undefined
75
+ if (version.major > 5 || (version.major === 5 && version.minor >= 6)) return "gpt-implicit"
76
+ if ((version.major === 5 && version.minor < 6) || (version.major === 4 && version.minor === 1)) {
77
+ return "gpt-retention"
78
+ }
79
+ }
80
+
81
+ function isGptCachingMode(mode) {
82
+ return mode === "gpt-implicit" || mode === "gpt-retention"
83
+ }
84
+
85
+ function gptVersion(model) {
86
+ const match = model.match(/(?:^|[^a-z0-9])gpt[-_]?([0-9]+)(?:[._-]([0-9]+))?/i)
87
+ return match && { major: Number(match[1]), minor: Number(match[2] || 0) }
88
+ }
89
+
90
+ function addCacheControlToLast(messages, type) {
91
+ for (let index = messages.length - 1; index >= 0; index--) {
92
+ if (messageType(messages[index]) === type && hasTextContent(messages[index])) {
93
+ messages[index] = withCacheControl(messages[index])
94
+ return
95
+ }
96
+ }
97
+ }
98
+
99
+ function messageType(message) {
100
+ return message._getType?.() || message.type
101
+ }
102
+
103
+ function hasTextContent(message) {
104
+ return (
105
+ (typeof message.content === "string" && message.content.length > 0) ||
106
+ (Array.isArray(message.content) &&
107
+ message.content.some((block) => block?.type === "text" && block.text?.length > 0))
108
+ )
109
+ }
110
+
111
+ function withCacheControl(message) {
112
+ const content = message.content
113
+ if (typeof content === "string") {
114
+ return cloneMessage(message, [
115
+ { type: "text", text: content, cache_control: CACHE_CONTROL_EPHEMERAL },
116
+ ])
117
+ }
118
+ if (!Array.isArray(content)) return message
119
+
120
+ for (let index = content.length - 1; index >= 0; index--) {
121
+ if (content[index]?.type === "text") {
122
+ const nextContent = [...content]
123
+ nextContent[index] = { ...nextContent[index], cache_control: CACHE_CONTROL_EPHEMERAL }
124
+ return cloneMessage(message, nextContent)
125
+ }
126
+ }
127
+ return message
128
+ }
129
+
130
+ function cloneMessage(message, content) {
131
+ return Object.assign(Object.create(Object.getPrototypeOf(message)), message, { content })
132
+ }
@@ -10,12 +10,6 @@ function resolveI18n(value, locale) {
10
10
  return value
11
11
  }
12
12
 
13
- export function getAgentLogger(srv) {
14
- const slug = slugified(srv.name)
15
- const agentSlug = slug.endsWith("-agent") ? slug : `${slug}-agent`
16
- return cds.log("agents:" + srv.name, { label: agentSlug })
17
- }
18
-
19
13
  /**
20
14
  * Mirrors CAP's internal slug rules used for service path generation.
21
15
  */
@@ -57,10 +51,11 @@ export function getFilteredEntities(srv) {
57
51
  Object.entries(srv.entities || {})
58
52
  .filter(
59
53
  ([name, entity]) =>
60
- !(entity["@cds.autoexposed"] && !entity["@cds.autoexpose"]) &&
54
+ !entity["@cds.autoexposed"] &&
55
+ !entity["@cds.api.ignore"] &&
61
56
  !name.endsWith("DraftAdministrativeData") &&
62
- !name.endsWith(".texts") &&
63
- !entity["@cds.api.ignore"],
57
+ !name.endsWith(".drafts") &&
58
+ !name.endsWith(".texts"),
64
59
  )
65
60
  .sort(([a], [b]) => a.localeCompare(b)),
66
61
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/agents",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "CDS plugin for building agents",
5
5
  "author": "SAP SE (https://www.sap.com)",
6
6
  "license": "Apache-2.0",
@@ -41,7 +41,7 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "@a2a-js/sdk": "^0.3.12",
44
- "@cap-js/attachments": "^4",
44
+ "@cap-js/attachments": ">=3",
45
45
  "@cap-js/mcp": "^1.4",
46
46
  "@langchain/anthropic": "^1.5.1",
47
47
  "@langchain/core": "^1",
@@ -147,7 +147,7 @@
147
147
  "image/jpeg"
148
148
  ]
149
149
  },
150
- "pool": {
150
+ "quotas": {
151
151
  "maxConcurrentTasks": 10,
152
152
  "maxConcurrentTasksPerUser": 4,
153
153
  "maxTasksPerHour": 100,
@@ -159,7 +159,6 @@
159
159
  "maxLLMTokensPerTask": 200000,
160
160
  "maxLLMCallTimeout": "120s",
161
161
  "maxExecutionTimePerTask": "5min",
162
- "timeoutGrace": "15s",
163
162
  "maxIncomingMessageLength": 5000
164
163
  }
165
164
  },
@@ -190,7 +189,7 @@
190
189
  }
191
190
  },
192
191
  "[development]": {
193
- "llm": "mock"
192
+ "llm": "auto"
194
193
  },
195
194
  "[with-claude]": {
196
195
  "llm": "anthropic"
@@ -1,10 +1,27 @@
1
1
  import cds from "@sap/cds"
2
2
  import { agentMessage, firstDataPart, partsToText } from "../../../lib/utils/message-handling.js"
3
3
  import { audit, short } from "../../../lib/utils/utils.js"
4
+ import * as metrics from "../../../lib/telemetry/metrics.js"
4
5
 
5
6
  const LOG = cds.log("agents")
6
7
 
7
8
  export const HITL_METADATA_KEY = "sap.cds.agents.hitl"
9
+ export const TIMEOUT_HITL_METADATA_KEY = "sap.cds.agents.timeout-hitl"
10
+ export const INPUT_REQUIRED_METADATA_KEY = "sap.cds.agents.input-required"
11
+
12
+ function approvalOptions() {
13
+ return [
14
+ { value: "approve", label: cds.i18n.messages.at("HITL_APPROVE") },
15
+ { value: "reject", label: cds.i18n.messages.at("HITL_REJECT") },
16
+ ]
17
+ }
18
+
19
+ function timeoutOptions() {
20
+ return [
21
+ { value: "continue", label: cds.i18n.messages.at("HITL_CONTINUE") },
22
+ { value: "reject", label: cds.i18n.messages.at("HITL_STOP") },
23
+ ]
24
+ }
8
25
  export const requiresHitl = (result) =>
9
26
  result?.__interrupt__?.length > 0 || result?.interrupts?.length > 0
10
27
 
@@ -22,6 +39,21 @@ export function parseResumeDecision(userText) {
22
39
  }
23
40
  }
24
41
 
42
+ export function patchRejectMessage(dataPart) {
43
+ if (!Array.isArray(dataPart?.decisions)) return dataPart
44
+ return {
45
+ ...dataPart,
46
+ decisions: dataPart.decisions.map((decision) =>
47
+ decision?.type === "reject"
48
+ ? {
49
+ ...decision,
50
+ message: `The user rejected this particular tool invocation with the reason: ${decision.message ?? ""}`,
51
+ }
52
+ : decision,
53
+ ),
54
+ }
55
+ }
56
+
25
57
  function decisionsForAudit(resume, actionRequests = []) {
26
58
  if (!Array.isArray(resume?.decisions)) return [{ action: null, decision: resume }]
27
59
  return resume.decisions.map((decision, index) => ({
@@ -30,6 +62,25 @@ function decisionsForAudit(resume, actionRequests = []) {
30
62
  }))
31
63
  }
32
64
 
65
+ function hitlMetricAttrs(serviceName, action, decision) {
66
+ return {
67
+ ...metrics.attrs(serviceName),
68
+ "agent.hitl.action": action?.name,
69
+ ...(decision && { "agent.hitl.decision": decision }),
70
+ }
71
+ }
72
+
73
+ function recordHitlDecisions(serviceName, actionRequests, resume, actionOffset = 0) {
74
+ if (!Array.isArray(resume?.decisions)) return
75
+ for (const [index, decision] of resume.decisions.entries()) {
76
+ if (!decision?.type) continue
77
+ metrics.hitlDecisions.add(
78
+ 1,
79
+ hitlMetricAttrs(serviceName, actionRequests[index + actionOffset], decision.type),
80
+ )
81
+ }
82
+ }
83
+
33
84
  function extractInterruptDescription(resultOrErr) {
34
85
  const interrupt = resultOrErr.__interrupt__?.[0] || resultOrErr.interrupts?.[0]
35
86
  const payload = interrupt?.value
@@ -173,7 +224,10 @@ function publishInputRequired({ requestContext, eventBus, description, interrupt
173
224
  contextId,
174
225
  status: {
175
226
  state: "input-required",
176
- message: agentMessage(description, interruptData, { [HITL_METADATA_KEY]: pending }),
227
+ message: agentMessage(description, interruptData, {
228
+ [HITL_METADATA_KEY]: pending,
229
+ [INPUT_REQUIRED_METADATA_KEY]: { options: approvalOptions() },
230
+ }),
177
231
  timestamp: new Date().toISOString(),
178
232
  },
179
233
  final: true,
@@ -181,6 +235,64 @@ function publishInputRequired({ requestContext, eventBus, description, interrupt
181
235
  eventBus.finished()
182
236
  }
183
237
 
238
+ export function isTimeoutHitl(task) {
239
+ return task?.status?.message?.metadata?.[TIMEOUT_HITL_METADATA_KEY] === true
240
+ }
241
+
242
+ export function publishTimeoutHitl({ requestContext, eventBus, description, serviceName }) {
243
+ const { taskId, contextId } = requestContext
244
+ LOG.info(serviceName, "-", "timeout awaiting decision", { conversation: short(contextId) })
245
+ audit("AgentInputRequired", {
246
+ data: { taskId, contextId, service: serviceName, reason: "timeout", description },
247
+ })
248
+ eventBus.publish({
249
+ kind: "status-update",
250
+ taskId,
251
+ contextId,
252
+ status: {
253
+ state: "input-required",
254
+ message: agentMessage(description, undefined, {
255
+ [TIMEOUT_HITL_METADATA_KEY]: true,
256
+ [INPUT_REQUIRED_METADATA_KEY]: { options: timeoutOptions() },
257
+ }),
258
+ timestamp: new Date().toISOString(),
259
+ },
260
+ final: true,
261
+ })
262
+ }
263
+
264
+ export async function resumeTimeoutHitl({ requestContext, eventBus, stream, signal }) {
265
+ const { taskId, contextId } = requestContext
266
+ const decision = partsToText(requestContext.userMessage?.parts).trim()
267
+ if (!decision) throw new Error(cds.i18n.messages.at("RESUME_REQUIRES_TEXT"))
268
+
269
+ if (/^(continue|approve|yes|confirm|ok)$/i.test(decision)) {
270
+ LOG.info("timeout continuation approved", { conversation: short(contextId) })
271
+ audit("AgentTaskResumed", {
272
+ data: { taskId, contextId, service: cds.context?.["agent.service"], reason: "timeout" },
273
+ })
274
+ const resumed = await stream(null, signal)
275
+ return resumed.state
276
+ }
277
+
278
+ LOG.info("timeout continuation declined", { conversation: short(contextId) })
279
+ audit("AgentTaskCanceled", {
280
+ data: { taskId, contextId, service: cds.context?.["agent.service"], reason: "timeout" },
281
+ })
282
+ eventBus.publish({
283
+ kind: "status-update",
284
+ taskId,
285
+ contextId,
286
+ status: {
287
+ state: "canceled",
288
+ message: agentMessage("Task stopped by user after timeout."),
289
+ timestamp: new Date().toISOString(),
290
+ },
291
+ final: true,
292
+ })
293
+ return undefined
294
+ }
295
+
184
296
  export async function resumeHitl({ requestContext, graph, config, eventBus, stream, signal }) {
185
297
  const { taskId, contextId } = requestContext
186
298
  const dataPart = firstDataPart(requestContext.userMessage?.parts)
@@ -189,14 +301,16 @@ export async function resumeHitl({ requestContext, graph, config, eventBus, stre
189
301
  throw new Error(cds.i18n.messages.at("RESUME_REQUIRES_TEXT"))
190
302
  }
191
303
  const { Command } = await import("@langchain/langgraph")
192
- let resume = dataPart !== undefined ? dataPart : parseResumeDecision(userText)
304
+ let resume = dataPart !== undefined ? patchRejectMessage(dataPart) : parseResumeDecision(userText)
193
305
  let actionRequests = []
194
306
 
195
307
  if (Array.isArray(resume?.decisions)) {
196
308
  const pending = pendingHitlFromTask(requestContext.task)
197
309
  const actionCount = pending?.actionCount ?? (await getPendingHitlActionCount(graph, config))
198
310
  actionRequests = pendingActionRequests(requestContext.task, pending)
311
+ const priorDecisionCount = pending?.decisions?.length || 0
199
312
  const decisions = [...(pending?.decisions || []), ...resume.decisions]
313
+ recordHitlDecisions(cds.context?.["agent.service"], actionRequests, resume, priorDecisionCount)
200
314
  if (decisions.length < actionCount) {
201
315
  const interruptData = firstDataPart(requestContext.task?.status?.message?.parts)
202
316
  const nextPending = { ...pending, actionCount, decisions }
@@ -243,7 +357,11 @@ export function handleHitlInterrupt({
243
357
  const { taskId, contextId } = requestContext
244
358
  const description = extractInterruptDescription(result)
245
359
  const interruptData = extractInterruptData(result)
246
- LOG.info("input-required", { conversation: short(contextId), service: serviceName, duration })
360
+ const actionRequests = interruptData?.actionRequests || interruptActionRequests(result)
361
+ for (const action of actionRequests) {
362
+ metrics.hitlGates.add(1, hitlMetricAttrs(serviceName, action))
363
+ }
364
+ LOG.info(serviceName, "-", "input-required", { conversation: short(contextId), duration })
247
365
  onInputRequired?.(description)
248
366
  audit("AgentInputRequired", {
249
367
  data: { taskId, contextId, service: serviceName, description, interruptData },