@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.
@@ -70,15 +70,20 @@ async function _flushValidations(state) {
70
70
  tasks.push(
71
71
  flushMlflowTraces().then(() =>
72
72
  Promise.all([
73
- postMlflowAssessment(traceId, success_rate, "", "success_rate", null, codeOpts).catch(
74
- () => {},
75
- ),
73
+ postMlflowAssessment(
74
+ traceId,
75
+ success_rate,
76
+ "",
77
+ "success_rate",
78
+ "cap-js/agents",
79
+ codeOpts,
80
+ ).catch(() => {}),
76
81
  postMlflowAssessment(
77
82
  traceId,
78
83
  output_correctness,
79
84
  "",
80
85
  "output_correctness",
81
- null,
86
+ "cap-js/agents",
82
87
  codeOpts,
83
88
  ).catch(() => {}),
84
89
  ]),
@@ -124,7 +129,7 @@ async function _postAssessmentScore(result, score, comment, config) {
124
129
  score,
125
130
  comment ?? "",
126
131
  config.assessmentName,
127
- config.model ?? null,
132
+ config.model ?? config.assessmentName ?? "judge",
128
133
  { ...opts, metadata: { "mlflow.trace.session": config.sessionId ?? "" } },
129
134
  )
130
135
  } else {
@@ -134,7 +139,7 @@ async function _postAssessmentScore(result, score, comment, config) {
134
139
  score,
135
140
  comment ?? "",
136
141
  config.assessmentName,
137
- config.model ?? null,
142
+ config.model ?? config.assessmentName ?? "judge",
138
143
  opts,
139
144
  )
140
145
  }
package/lib/index.js CHANGED
@@ -15,6 +15,9 @@ import * as metrics from "./telemetry/metrics.js"
15
15
 
16
16
  const LOG = cds.log("agents")
17
17
 
18
+ const TRUNCATE = cds.env.agents?.truncate || 111
19
+ const truncated = (msg) => (msg?.length > TRUNCATE ? msg.slice(0, TRUNCATE) + "..." : msg)
20
+
18
21
  // SSE wire-format helpers, mirroring @a2a-js/sdk's sse_utils so the adapter
19
22
  // streams message/stream responses identically to the SDK's reference
20
23
  // Express handler. Inlined (not imported) because the SDK does not export
@@ -215,11 +218,10 @@ export default function A2AProtocolAdapter(srv, options = {}) {
215
218
  if (method === "message/send" || method === "message/stream") {
216
219
  const text = userText
217
220
 
218
- const maxLen = cds.env.agents?.pool?.maxIncomingMessageLength
221
+ const maxLen = cds.env.agents?.quotas?.maxIncomingMessageLength
219
222
  if (maxLen > 0 && text?.length > maxLen) {
220
- LOG.warn("message too long", {
223
+ LOG.warn(srv.name, "message too long", {
221
224
  conversation: short(contextId),
222
- service: srv.name,
223
225
  })
224
226
  metrics.errorsTotal.add(1, { ...requestAttrs, "agent.error.code": 400 })
225
227
 
@@ -245,15 +247,13 @@ export default function A2AProtocolAdapter(srv, options = {}) {
245
247
  return
246
248
  }
247
249
 
248
- const truncated = text?.length > 80 ? text.slice(0, 80) + "..." : text
249
- LOG.info("request", {
250
+ LOG.info(srv.name, "request", {
250
251
  conversation: short(contextId),
251
- service: srv.name,
252
252
  method,
253
- text: truncated,
253
+ text: truncated(text),
254
254
  })
255
255
  } else {
256
- LOG.debug("request", { conversation: short(contextId), service: srv.name, method })
256
+ LOG.debug(srv.name, "request", { conversation: short(contextId), method })
257
257
  }
258
258
 
259
259
  try {
@@ -264,9 +264,8 @@ export default function A2AProtocolAdapter(srv, options = {}) {
264
264
  await import("./agents/quota-enforcer-at-start.js")
265
265
  const quotaResult = await quotaEnforcerAtStart()
266
266
  if (quotaResult) {
267
- LOG.warn("quota exceeded", {
267
+ LOG.warn(srv.name, "quota exceeded", {
268
268
  conversation: short(contextId),
269
- service: srv.name,
270
269
  reason: quotaResult.message,
271
270
  })
272
271
  metrics.errorsTotal.add(1, { ...requestAttrs, "agent.error.code": 429 })
@@ -416,8 +415,6 @@ export default function A2AProtocolAdapter(srv, options = {}) {
416
415
  })
417
416
  })
418
417
 
419
- LOG.debug("Adapter initialized", { service: srv.name })
420
-
421
418
  router.router = router
422
419
  return router
423
420
  }
@@ -2,24 +2,53 @@ import cds from "@sap/cds"
2
2
  import { OrchestrationClient } from "@sap-ai-sdk/langchain"
3
3
  import { circuitBreaker, timeout } from "../utils/resilience.js"
4
4
 
5
- import { SystemMessage, ToolMessage, HumanMessage, AIMessage } from "@langchain/core/messages"
5
+ import { SystemMessage, ToolMessage, AIMessage } from "@langchain/core/messages"
6
6
  import { ms4 } from "../utils/utils.js"
7
+ import {
8
+ isPromptCachingModel,
9
+ PROMPT_CACHE_MODEL_PARAMS,
10
+ withPromptCachingMessages,
11
+ withPromptCachingOptions,
12
+ withPromptCachingParams,
13
+ } from "../utils/caching.js"
7
14
  import { syncSystemPrompt } from "../telemetry/mlflow/prompts.js"
8
15
 
9
16
  const LOG = cds.log("agents")
10
17
  const DEFAULT_STREAM_DELIMITERS = [".", "!", "?"]
11
18
 
19
+ function noTemp0Support(model) {
20
+ switch (model) {
21
+ case "gpt-5.6-sol":
22
+ case "gpt-5.6-luna":
23
+ case "gpt-5.6-terra":
24
+ case "gpt-6-astra":
25
+ return true
26
+ default:
27
+ return false
28
+ }
29
+ }
30
+
31
+ const llmError = (err) => {
32
+ LOG.error(`AI Core request failed!`, err.rootCause || err)
33
+ cds.error(cds.i18n.messages.at("LLM_UNAVAILABLE"))
34
+ }
35
+
12
36
  class _InstrumentedOrchestrationClient extends OrchestrationClient {
13
37
  constructor(name, options) {
14
38
  const { model, deepAgent, streaming, destinationName, resourceGroup } = options
15
39
  const flatten = options.flatten ?? (deepAgent ? true : false)
16
40
 
17
- // Restore pre-72ef6fa params resolution: caller > deep-agent default > root
41
+ // params resolution: caller > deep-agent default > root
18
42
  // cds.env.agents.params. Without this, AI Core silently applies its own
19
43
  // defaults (temperature ≈ 1, verbose model → repeated max_tokens truncation
20
- // and extra ReAct iterations — see PR #188 review).
21
- const params =
44
+ // and extra ReAct iterations).
45
+ const rawParams =
22
46
  options.params || (deepAgent ? { max_tokens: 4096, temperature: 0 } : cds.env.agents?.params)
47
+ const params = withPromptCachingParams(model, rawParams)
48
+ if (params.temperature === 0 && noTemp0Support(model)) {
49
+ delete params.temperature
50
+ }
51
+
23
52
  const auditParams = params && typeof params === "object" ? { ...params } : params
24
53
 
25
54
  LOG.debug("Initializing LLM", { model, deepAgent: !!deepAgent })
@@ -65,14 +94,24 @@ class _InstrumentedOrchestrationClient extends OrchestrationClient {
65
94
  destination,
66
95
  )
67
96
  this.name = name
68
- this.options = { ...options, params: auditParams, contentFilter, flatten }
97
+ this.options = {
98
+ ...options,
99
+ params: auditParams,
100
+ contentFilter,
101
+ flatten,
102
+ promptCaching: isPromptCachingModel(model),
103
+ }
69
104
  }
70
105
 
71
106
  async _generate(messages, opts, runManager) {
72
107
  const { model, flatten } = this.options
73
108
  opts = _withMiddleware(this, opts)
74
109
  const prepared = _prepareMessages(messages, { flatten, model, opts })
75
- return super._generate(prepared.inputMessages, prepared.opts, runManager)
110
+ try {
111
+ return super._generate(prepared.inputMessages, prepared.opts, runManager)
112
+ } catch (err) {
113
+ llmError(err)
114
+ }
76
115
  }
77
116
 
78
117
  /**
@@ -80,8 +119,8 @@ class _InstrumentedOrchestrationClient extends OrchestrationClient {
80
119
  *
81
120
  * 1. Message flattening / normalization — same as _generate: content arrays
82
121
  * must be reduced to text before reaching AI Core's assistant template.
83
- * 2. Claude prompt caching — injectCacheControl must run here too, or
84
- * cache_control is silently skipped on the streaming path.
122
+ * 2. Prompt caching options — same as _generate, or caching is skipped on
123
+ * the streaming path.
85
124
  * 3. Content extraction — @sap-ai-sdk/langchain getDeltaContent() only handles
86
125
  * string deltas; Anthropic streams content-block arrays (and reasoning in a
87
126
  * sibling field), so we re-extract and re-emit them via buildStreamBlocks().
@@ -94,17 +133,44 @@ class _InstrumentedOrchestrationClient extends OrchestrationClient {
94
133
  opts = withDefaultStreamDelimiters(prepared.opts)
95
134
 
96
135
  let turnHasToolCall = false
97
- for await (const chunk of super._streamResponseChunks(inputMessages, opts, runManager)) {
98
- if (chunk.message?.tool_call_chunks?.length > 0) turnHasToolCall = true
99
- const text = chunk.text || extractTextFromContentBlocks(chunk)
100
- const reasoning = extractReasoningFromChunk(chunk)
101
- const blocks = buildStreamBlocks(text, reasoning, turnHasToolCall)
102
-
103
- if (!blocks.length && !(text && turnHasToolCall)) {
104
- yield chunk
105
- continue
136
+ try {
137
+ for await (const chunk of super._streamResponseChunks(inputMessages, opts, runManager)) {
138
+ if (chunk.message?.tool_call_chunks?.length > 0) turnHasToolCall = true
139
+ const text = chunk.text || extractTextFromContentBlocks(chunk)
140
+ const reasoning = extractReasoningFromChunk(chunk)
141
+ const blocks = buildStreamBlocks(text, reasoning, turnHasToolCall)
142
+
143
+ if (!blocks.length && !(text && turnHasToolCall)) {
144
+ yield chunk
145
+ continue
146
+ }
147
+ yield patchChunkContent(chunk, blocks)
106
148
  }
107
- yield patchChunkContent(chunk, blocks)
149
+ } catch (err) {
150
+ llmError(err)
151
+ }
152
+ }
153
+
154
+ mergeOrchestrationConfig(orchestrationConfig, options) {
155
+ const config = super.mergeOrchestrationConfig(orchestrationConfig, options)
156
+ const modelParams = options?.[PROMPT_CACHE_MODEL_PARAMS]
157
+ if (!modelParams) return config
158
+ const currentParams = config.promptTemplating?.model?.params || {}
159
+ const nextParams = { ...modelParams }
160
+ if (currentParams.prompt_cache_key !== undefined) delete nextParams.prompt_cache_key
161
+ if (!Object.keys(nextParams).length) return config
162
+ return {
163
+ ...config,
164
+ promptTemplating: {
165
+ ...config.promptTemplating,
166
+ model: {
167
+ ...config.promptTemplating?.model,
168
+ params: {
169
+ ...currentParams,
170
+ ...nextParams,
171
+ },
172
+ },
173
+ },
108
174
  }
109
175
  }
110
176
  }
@@ -113,15 +179,11 @@ export default _InstrumentedOrchestrationClient
113
179
 
114
180
  // ─── SDK helpers ─────────────────────────────────────────────────────────────
115
181
 
116
- function isClaude(model) {
117
- return /anthropic|claude/i.test(model || "")
118
- }
119
-
120
182
  /**
121
183
  * Inject timeout and circuit breaker middleware into opts.
122
184
  */
123
185
  function _withMiddleware(instance, opts) {
124
- const llmTimeout = ms4(cds.env.agents?.pool?.maxLLMCallTimeout || "120s")
186
+ const llmTimeout = ms4(cds.env.agents?.quotas?.maxLLMCallTimeout || "120s")
125
187
  const middleware = [timeout(llmTimeout), circuitBreaker()]
126
188
  return {
127
189
  ...opts,
@@ -130,23 +192,16 @@ function _withMiddleware(instance, opts) {
130
192
  }
131
193
 
132
194
  /**
133
- * Prepare input messages: flatten, normalize assistant content, inject cache_control.
134
- * Returns { inputMessages, opts } (opts may be mutated with cache_control on last tool).
195
+ * Prepare input messages: flatten, normalize assistant content, and set prompt
196
+ * caching call options/model params.
135
197
  */
136
198
  function _prepareMessages(messages, { flatten, model, opts }) {
137
199
  let inputMessages = flatten ? flattenMessages(messages) : messages
138
200
  inputMessages = normalizeAssistantContent(inputMessages)
139
- if (isClaude(model)) {
140
- inputMessages = injectCacheControl(inputMessages)
141
- if (opts?.tools?.length > 0) {
142
- const tools = [...opts.tools]
143
- tools[tools.length - 1] = {
144
- ...tools[tools.length - 1],
145
- cache_control: CACHE_CONTROL_EPHEMERAL,
146
- }
147
- opts = { ...opts, tools }
148
- }
149
- }
201
+ const cached = withPromptCachingMessages(model, inputMessages, opts)
202
+ inputMessages = cached.messages
203
+ opts = cached.opts
204
+ opts = withPromptCachingOptions(model, opts)
150
205
  syncSystemPrompt(inputMessages)
151
206
  return { inputMessages, opts }
152
207
  }
@@ -164,97 +219,6 @@ export function withDefaultStreamDelimiters(opts = {}) {
164
219
  }
165
220
  }
166
221
 
167
- // Claude currently only supports caching of type ephemeral. TTL can differ between 5min or 1h but
168
- // we use the 5min default
169
- const CACHE_CONTROL_EPHEMERAL = { type: "ephemeral" }
170
-
171
- /**
172
- * Marks: all system messages, the last AI message (with text content), and the last human message.
173
- * Converts string content to content-block arrays where needed so cache_control
174
- * can be attached per the Anthropic/SAP AI Core API format.
175
- */
176
- function injectCacheControl(messages) {
177
- if (!messages || messages.length === 0) return messages
178
-
179
- const result = messages.map((m) => {
180
- // Clone to avoid mutating original
181
- if (m._getType?.() === "system" || m.type === "system") {
182
- return _withCacheControl(m)
183
- }
184
- return m
185
- })
186
- // Mark last AI message with non-empty text content (stable breakpoint for multi-turn)
187
- for (let i = result.length - 1; i >= 0; i--) {
188
- const type = result[i]._getType?.() || result[i].type
189
- if (type === "ai" && _hasTextContent(result[i])) {
190
- result[i] = _withCacheControl(result[i])
191
- break
192
- }
193
- }
194
- // Mark last human message
195
- for (let i = result.length - 1; i >= 0; i--) {
196
- const type = result[i]._getType?.() || result[i].type
197
- if (type === "human") {
198
- result[i] = _withCacheControl(result[i])
199
- break
200
- }
201
- }
202
- return result
203
- }
204
-
205
- /**
206
- * Check if a message has non-empty text content (not just tool_calls).
207
- */
208
- function _hasTextContent(msg) {
209
- const content = msg.content
210
- if (typeof content === "string") return content.length > 0
211
- if (Array.isArray(content)) return content.some((b) => b.type === "text" && b.text?.length > 0)
212
- return false
213
- }
214
-
215
- /**
216
- * If content is a string, convert to [{type:"text", text, cache_control}].
217
- * If content is an array, add cache_control to the last text block.
218
- */
219
- function _withCacheControl(msg) {
220
- const content = msg.content
221
- if (typeof content === "string") {
222
- // Convert to content blocks with cache_control on the block
223
- const newContent = [{ type: "text", text: content, cache_control: CACHE_CONTROL_EPHEMERAL }]
224
- return _cloneMessageWithContent(msg, newContent)
225
- }
226
- if (Array.isArray(content) && content.length > 0) {
227
- const newContent = [...content]
228
- // Find last text block and add cache_control
229
- for (let i = newContent.length - 1; i >= 0; i--) {
230
- if (newContent[i].type === "text") {
231
- newContent[i] = { ...newContent[i], cache_control: CACHE_CONTROL_EPHEMERAL }
232
- break
233
- }
234
- }
235
- return _cloneMessageWithContent(msg, newContent)
236
- }
237
- return msg
238
- }
239
-
240
- function _cloneMessageWithContent(msg, newContent) {
241
- const type = msg._getType?.() || msg.type
242
- if (type === "system") {
243
- return new SystemMessage({ content: newContent })
244
- }
245
- if (type === "ai") {
246
- return new AIMessage({ content: newContent, tool_calls: msg.tool_calls })
247
- }
248
- if (type === "human") {
249
- return new HumanMessage({ content: newContent })
250
- }
251
- if (type === "tool") {
252
- return new ToolMessage({ content: newContent, tool_call_id: msg.tool_call_id, name: msg.name })
253
- }
254
- // Fallback: shallow clone with new content
255
- return { ...msg, content: newContent }
256
- }
257
-
258
222
  export function flattenMessages(messages) {
259
223
  return messages.map((m) => {
260
224
  if (!Array.isArray(m.content)) return m
@@ -1,92 +1,20 @@
1
1
  import { ChatAnthropic } from '@langchain/anthropic'
2
- import path from 'node:path'
3
- import fs from 'node:fs'
4
- import os from 'node:os'
5
- import cds from '@sap/cds'
2
+ import { withPromptCachingMessages } from '../utils/caching.js'
6
3
 
7
- const HOME = os.homedir() || process.env.HOME || process.env.USERPROFILE
8
- const local = file => file.replace(HOME,'~')
9
- const LOG = cds.log('agents')
10
-
11
- /**
12
- * `cds.connect.to` compliant langchain model
13
- * for connecting to an Anthropic compatible API,
14
- * with autoconfiguration based on env, options,
15
- * ~/.claude/settings.json and ~/.config/opencode/opencode.json
16
- */
17
4
  export default class ChatAnthropicService extends ChatAnthropic {
18
- constructor (name, options) {
19
- let config = { ...options, ...options?.credentials, ...fromEnv() }
20
- if (!config.anthropicApiUrl) config = {
21
- ...fromClaude() || fromOpencode(),
22
- ...config
23
- }
24
- if (LOG._debug) {
25
- const { kind, model, anthropicApiUrl, apiKey } = config
26
- LOG.info (`Using effective config:`, {
27
- kind,
28
- model,
29
- credentials: {
30
- anthropicApiUrl,
31
- apiKey: apiKey ? '***' : undefined
32
- }
33
- })
34
- }
35
- super (config)
5
+ constructor (name, options = {}) {
6
+ const { credentials = {} } = options
7
+ super ({ ...credentials, ...options })
36
8
  this.name = name
37
- this.options = config
38
9
  }
39
- }
40
-
41
- function fromEnv (env = process.env, silent) {
42
- let any, config = {}
43
- if ((any = env.ANTHROPIC_BASE_URL)) config.anthropicApiUrl = any
44
- if ((any = env.ANTHROPIC_AUTH_TOKEN)) config.apiKey = any
45
- if ((any = env.ANTHROPIC_API_KEY)) config.apiKey = any
46
- if ((any = env.ANTHROPIC_MODEL)) config.model = any
47
- if (!Object.keys(config).length) return null
48
- if (!silent) LOG.debug (`Loaded Anthropic settings from env:`, config)
49
- return config
50
- }
51
-
52
- function fromClaude() {
53
- if ('cached' in fromClaude) return fromClaude.cached
54
- const settings_json = path.join (HOME,'.claude/settings.json')
55
- try {
56
- let settings = JSON.parse (fs.readFileSync (settings_json,'utf8'))
57
- // https://www.schemastore.org/claude-code-settings.json
58
10
 
59
- let conf = fromClaude.cached = fromEnv (settings?.env, 'silent')
60
- if (!conf.model && settings.env) {
61
- let family = (settings.model||'sonnet').toUpperCase()
62
- conf.model = settings.env[`ANTHROPIC_DEFAULT_${family}_MODEL`] || settings?.model
63
- }
64
- LOG.debug(`Loaded Claude settings from`, local(settings_json), ':', conf)
65
- } catch {
66
- LOG.debug(`Failed loading Claude settings from`, local(settings_json))
67
- fromClaude.cached = null
11
+ async _generate (messages, options, runManager) {
12
+ const cached = withPromptCachingMessages(this.model, messages, options)
13
+ return super._generate(cached.messages, cached.opts, runManager)
68
14
  }
69
- return fromClaude.cached
70
- }
71
15
 
72
- function fromOpencode() {
73
- if ('cached' in fromOpencode) return fromOpencode.cached
74
- const opencode_json = path.join (HOME,'.config/opencode/opencode.json')
75
- try {
76
- let conf = JSON.parse (fs.readFileSync (opencode_json,'utf8'))
77
- LOG.debug(`Loaded OpenCode settings from`, local(opencode_json))
78
- // https://opencode.ai/config.json
79
- let o = conf?.provider?.anthropic?.options
80
- if (!o) return fromOpencode.cached = null
81
- let any, config = {}
82
- if ((any = o.anthropicApiUrl ?? o.apiUrl ?? o.baseURL)) config.anthropicApiUrl = any.replace(/\/v1$/,'') // opencode expects the versioned baseUrl, others do not
83
- if ((any = o.anthropicApiKey ?? o.apiKey)) config.apiKey = any
84
- if ((any = conf?.model)) config.model = any.replace('anthropic/','')
85
- fromOpencode.cached = Object.keys(config).length ? config : null
86
- LOG.debug(`Loaded OpenCode settings from`, local(opencode_json), ':', conf)
87
- } catch {
88
- LOG.debug(`Failed loading OpenCode settings from`, local(opencode_json))
89
- fromOpencode.cached = null
16
+ async *_streamResponseChunks (messages, options, runManager) {
17
+ const cached = withPromptCachingMessages(this.model, messages, options)
18
+ yield* super._streamResponseChunks(cached.messages, cached.opts, runManager)
90
19
  }
91
- return fromOpencode.cached
92
20
  }
@@ -115,7 +115,6 @@
115
115
  color: #1d3e5a;
116
116
  border-bottom-left-radius: 3px;
117
117
  border-left: 3px solid #537492;
118
- white-space: pre-wrap;
119
118
  }
120
119
  .msg.approval::before {
121
120
  content: "Action Required";
@@ -127,6 +126,15 @@
127
126
  letter-spacing: 0.03em;
128
127
  text-transform: uppercase;
129
128
  }
129
+ .msg.approval pre {
130
+ margin: 8px 0 0;
131
+ padding: 8px;
132
+ overflow-x: auto;
133
+ border-radius: 6px;
134
+ background: #e7f4ff;
135
+ font-size: 1.1em;
136
+ white-space: pre;
137
+ }
130
138
 
131
139
  .msg.agent p {
132
140
  margin: 0 0 6px;
@@ -372,6 +380,9 @@
372
380
  .msg.approval::before {
373
381
  color: #91adc5;
374
382
  }
383
+ .msg.approval pre {
384
+ background: #0f2d45;
385
+ }
375
386
  .btn-approve {
376
387
  background: #537492;
377
388
  }
@@ -811,31 +822,41 @@
811
822
  setTimeout(finish, THINKING_SUMMARY_ANIMATION_MS + 80)
812
823
  }
813
824
 
814
- function addApprovalPrompt(description) {
825
+ function addApprovalPrompt(description, options, actionRequest) {
815
826
  const el = document.createElement("div")
816
827
  el.className = "msg approval"
817
- el.textContent = description || "The agent requires your approval to continue."
828
+ const content = document.createElement("div")
829
+ content.textContent = actionRequest?.name
830
+ ? `Tool execution requires approval\n\nTool: ${actionRequest.name}`
831
+ : description || "The agent requires your approval to continue."
832
+ el.appendChild(content)
833
+ content.innerHTML = md(content.textContent)
834
+ if (actionRequest?.args !== undefined) {
835
+ const json = document.createElement("pre")
836
+ json.textContent = JSON.stringify(actionRequest.args, null, 2)
837
+ el.appendChild(json)
838
+ }
818
839
  const actions = document.createElement("div")
819
840
  actions.className = "approval-actions"
820
841
 
821
- const approveBtn = document.createElement("button")
822
- approveBtn.className = "btn-approve"
823
- approveBtn.textContent = "Approve"
824
- approveBtn.addEventListener("click", () => {
825
- removeApprovalPrompt(el)
826
- resume("approve")
827
- })
828
-
829
- const rejectBtn = document.createElement("button")
830
- rejectBtn.className = "btn-reject"
831
- rejectBtn.textContent = "Reject"
832
- rejectBtn.addEventListener("click", () => {
833
- removeApprovalPrompt(el)
834
- resume("reject")
835
- })
836
-
837
- actions.appendChild(approveBtn)
838
- actions.appendChild(rejectBtn)
842
+ const choices =
843
+ Array.isArray(options) && options.length
844
+ ? options
845
+ : [
846
+ { value: "approve", label: "Approve" },
847
+ { value: "reject", label: "Reject" },
848
+ ]
849
+ for (const option of choices) {
850
+ if (typeof option?.value !== "string" || typeof option?.label !== "string") continue
851
+ const button = document.createElement("button")
852
+ button.className = option.value === "reject" ? "btn-reject" : "btn-approve"
853
+ button.textContent = option.label
854
+ button.addEventListener("click", () => {
855
+ removeApprovalPrompt(el)
856
+ resume(option.value)
857
+ })
858
+ actions.appendChild(button)
859
+ }
839
860
  el.appendChild(actions)
840
861
  messages.insertBefore(el, typing.parentElement === messages ? typing : statusText)
841
862
  messages.scrollTop = messages.scrollHeight
@@ -927,8 +948,12 @@
927
948
  pendingTaskId = taskId
928
949
  const msgParts = result?.status?.message?.parts ?? []
929
950
  const description = partsToText(msgParts)
951
+ const options =
952
+ result?.status?.message?.metadata?.["sap.cds.agents.input-required"]?.options
953
+ const actionRequest = msgParts.find((part) => part.kind === "data")?.data
954
+ ?.actionRequests?.[0]
930
955
  flushPendingThinkingUpdate()
931
- addApprovalPrompt(description)
956
+ addApprovalPrompt(description, options, actionRequest)
932
957
  return
933
958
  }
934
959
 
@@ -62,7 +62,7 @@ export function _patchChatModelProto(proto) {
62
62
  const provider = _resolveProvider()
63
63
  const node = opts?.runName || "agent"
64
64
  const messages = Array.isArray(input) ? input : undefined
65
- const cacheControl = _detectCacheControl(messages)
65
+ const cacheControl = _detectCacheRequest({ messages, opts, modelOptions: this.options })
66
66
  const streaming = this.streaming || this.orchestrationConfig?.streaming || false
67
67
  const mAttrs = _metricAttrs(model, node)
68
68
 
@@ -162,8 +162,18 @@ function _metricAttrs(model, node) {
162
162
  }
163
163
  }
164
164
 
165
- /** Detect cache_control presence in messages (set by injectCacheControl in aicore). */
166
- function _detectCacheControl(messages) {
165
+ function _detectCacheRequest({ messages, opts, modelOptions }) {
166
+ const params = modelOptions?.params
167
+ return Boolean(
168
+ _messagesHaveCacheControl(messages) ||
169
+ opts?.cache_control ||
170
+ modelOptions?.promptCaching ||
171
+ params?.prompt_cache_retention ||
172
+ params?.prompt_cache_options,
173
+ )
174
+ }
175
+
176
+ function _messagesHaveCacheControl(messages) {
167
177
  if (!Array.isArray(messages)) return false
168
178
  return messages.some((m) => {
169
179
  if (!Array.isArray(m.content)) return false
@@ -53,6 +53,12 @@ export const llmInvocations = meter.createCounter("agent.llm.invocations", {
53
53
  export const toolInvocations = meter.createCounter("agent.tool.invocations", {
54
54
  description: "Tool invocation count",
55
55
  })
56
+ export const hitlGates = meter.createCounter("agent.hitl.gates", {
57
+ description: "HITL action gates requested",
58
+ })
59
+ export const hitlDecisions = meter.createCounter("agent.hitl.decisions", {
60
+ description: "HITL decisions by action",
61
+ })
56
62
 
57
63
  /** Common attributes for all agent metrics */
58
64
  export function attrs(srv) {
@@ -11,7 +11,7 @@ export class DatabricksExporter extends MlflowExporter {
11
11
  ) {
12
12
  const { uc, warehouseId } = this._creds
13
13
  const ucPrefix = `${uc.catalog}.${uc.schema}.${uc.tablePrefix}`
14
- let url = `/api/4.0/mlflow/traces/${encodeURIComponent(ucPrefix)}/tr-${traceId}/assessments`
14
+ let url = `/api/4.0/mlflow/traces/${encodeURIComponent(ucPrefix)}/${traceId}/assessments`
15
15
  if (warehouseId) url += `?sql_warehouse_id=${encodeURIComponent(warehouseId)}`
16
16
  await this._fetch(url, {
17
17
  trace_id: traceId,