@cap-js/agents 0.9.7 → 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 (37) hide show
  1. package/_i18n/messages.properties +2 -0
  2. package/cds-plugin.js +13 -10
  3. package/lib/agents/middleware/content-filter.js +4 -0
  4. package/lib/agents/middleware/hitl-decision-note-injector.js +18 -6
  5. package/lib/agents/middleware/hitl.js +17 -6
  6. package/lib/agents/middleware/index.js +1 -1
  7. package/lib/agents/middleware/masking.js +6 -3
  8. package/lib/agents/middleware/remote-mcp.js +5 -2
  9. package/lib/agents/middleware/status-update.js +209 -76
  10. package/lib/agents/middleware/tool-wrap.js +1 -2
  11. package/lib/agents/summarize-on-timeout.js +5 -2
  12. package/lib/config/local.js +121 -8
  13. package/lib/eval/eval-run.js +50 -4
  14. package/lib/masking/unstructured/hana.js +13 -5
  15. package/lib/models/aicore.js +16 -3
  16. package/lib/models/openai.js +9 -0
  17. package/lib/preview/chat.html +561 -93
  18. package/lib/telemetry/chat-tracing.js +2 -0
  19. package/lib/telemetry/mlflow/evaluation.js +83 -4
  20. package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +39 -5
  21. package/lib/telemetry/mlflow/exporter/MlflowExporter.js +43 -4
  22. package/lib/telemetry/mlflow/index.js +1 -7
  23. package/lib/telemetry/mlflow/prompts.js +15 -11
  24. package/lib/utils/markdown.js +1 -8
  25. package/lib/utils/resilience.js +2 -1
  26. package/lib/utils/toml.js +67 -0
  27. package/lib/utils/usage.js +34 -0
  28. package/lib/utils/utils.js +43 -1
  29. package/package.json +31 -26
  30. package/srv/handlers/graph-executor/crash-handler.js +47 -0
  31. package/srv/handlers/graph-executor/hitl.js +27 -0
  32. package/srv/handlers/graph-executor.js +19 -5
  33. package/srv/handlers/index.js +6 -1
  34. package/srv/handlers/mcp-tools.js +11 -2
  35. package/srv/handlers/subagent-tools.js +13 -4
  36. package/srv/handlers/system-prompt.js +30 -27
  37. package/srv/handlers/tools.js +14 -13
@@ -2,26 +2,35 @@ import path from "node:path"
2
2
  import fs from "node:fs"
3
3
  import os from "node:os"
4
4
  import cds from "@sap/cds"
5
+ import { toml } from "../utils/toml.js"
5
6
 
6
7
  const HOME = os.homedir() || process.env.HOME || process.env.USERPROFILE
7
8
  const local = (file) => file.replace(HOME, "~")
8
9
  const LOG = cds.log("agents")
9
10
 
10
11
  /**
11
- * `cds.connect.to` compliant langchain model
12
- * for connecting to an Anthropic compatible API,
13
- * with autoconfiguration based on env, options,
14
- * ~/.claude/settings.json and ~/.config/opencode/opencode.json
12
+ * Autoconfiguration for anthropic -> openai -> mock
15
13
  */
16
14
  export function resolve_config(options) {
17
- let config = fromEnv()
15
+ let config = resolve_anthropic_config(options)
16
+ if (config?.credentials?.anthropicApiUrl || config?.credentials?.apiKey) return config
17
+ config = resolve_openai_config(options)
18
+ if (config?.credentials?.baseURL || config?.credentials?.apiKey) return config
19
+ return { kind: "mock" }
20
+ }
21
+
22
+ /**
23
+ * Anthropic autoconfiguration based on env, options,
24
+ * ~/.claude/settings.json and ~/.config/opencode/opencode.json
25
+ */
26
+ export function resolve_anthropic_config(options) {
27
+ let config = fromAnthropicEnv()
18
28
  if (!config?.anthropicApiUrl)
19
29
  config = {
20
30
  ...config,
21
31
  ...(fromClaude() || fromOpencode()),
22
32
  }
23
33
  let { model, ...credentials } = config
24
- if (!model && !options?.model) return { kind: "mock" }
25
34
  return {
26
35
  kind: "anthropic",
27
36
  model: options?.model || model,
@@ -32,7 +41,7 @@ export function resolve_config(options) {
32
41
  }
33
42
  }
34
43
 
35
- function fromEnv(env = process.env, silent) {
44
+ function fromAnthropicEnv(env = process.env, silent) {
36
45
  let any,
37
46
  config = {}
38
47
  if ((any = env.ANTHROPIC_BASE_URL)) config.anthropicApiUrl = any
@@ -51,7 +60,7 @@ function fromClaude() {
51
60
  let settings = JSON.parse(fs.readFileSync(settings_json, "utf8"))
52
61
  // https://www.schemastore.org/claude-code-settings.json
53
62
 
54
- let conf = (fromClaude.cached = fromEnv(settings?.env, "silent"))
63
+ let conf = (fromClaude.cached = fromAnthropicEnv(settings?.env, "silent"))
55
64
  if (!conf.model && settings.env) {
56
65
  let family = (settings.model || "sonnet").toUpperCase()
57
66
  conf.model = settings.env[`ANTHROPIC_DEFAULT_${family}_MODEL`] || settings?.model
@@ -88,6 +97,110 @@ function fromOpencode() {
88
97
  return fromOpencode.cached
89
98
  }
90
99
 
100
+ /**
101
+ * OpenAI autoconfiguration based on env, options,
102
+ * ~/.codex/config.toml and ~/.config/opencode/opencode.json
103
+ */
104
+ export function resolve_openai_config(options = {}) {
105
+ let config = fromOpenAIEnv()
106
+ if (!config?.baseURL)
107
+ config = {
108
+ ...config,
109
+ ...fromCodex(),
110
+ ...fromOpencodeOpenAI(),
111
+ }
112
+ let { model, ...credentials } = config
113
+ return {
114
+ kind: "openai",
115
+ model: options?.model || model,
116
+ credentials: {
117
+ ...credentials,
118
+ ...options?.credentials,
119
+ },
120
+ }
121
+ }
122
+
123
+ function fromOpenAIEnv(env = process.env) {
124
+ let any,
125
+ config = {}
126
+ if ((any = env.OPENAI_BASE_URL)) config.baseURL = any
127
+ if ((any = env.OPENAI_API_KEY)) config.apiKey = any
128
+ if ((any = env.OPENAI_MODEL)) config.model = any
129
+ if (!Object.keys(config).length) return null
130
+ LOG.debug(`Loaded OpenAI settings from env:`, config)
131
+ return config
132
+ }
133
+
134
+ function fromCodex() {
135
+ if ("cached" in fromCodex) return fromCodex.cached
136
+ const codex_toml = path.join(HOME, ".codex/config.toml")
137
+ try {
138
+ let conf = toml.parse(fs.readFileSync(codex_toml, "utf8"))
139
+ LOG.debug(`Loaded Codex settings from`, local(codex_toml))
140
+ const provider = conf.model_provider ?? "openai"
141
+ const p = conf.model_providers?.[provider] ?? {}
142
+ let apiKey = (p.env_key && process.env[p.env_key]) || p.experimental_bearer_token
143
+ if (!apiKey && p.requires_openai_auth) apiKey = fromCodexAuth()
144
+ let any,
145
+ config = {}
146
+ if ((any = p.base_url)) config.baseURL = any
147
+ if (
148
+ (any =
149
+ (p.env_key && process.env[p.env_key]) ??
150
+ p.experimental_bearer_token ??
151
+ (p.requires_openai_auth && fromCodexAuth()))
152
+ )
153
+ config.apiKey = any
154
+ if ((any = conf.model)) config.model = any
155
+ fromCodex.cached = Object.keys(config).length ? config : null
156
+ LOG.debug(`Loaded config from`, local(codex_toml), ":", sanitized(config))
157
+ } catch {
158
+ LOG.debug(`Failed loading Codex settings from`, local(codex_toml))
159
+ fromCodex.cached = null
160
+ }
161
+ return fromCodex.cached
162
+ }
163
+
164
+ function fromCodexAuth() {
165
+ const auth_json = path.join(HOME, ".codex/auth.json")
166
+ try {
167
+ let auth = JSON.parse(fs.readFileSync(auth_json, "utf8"))
168
+ // auth.json stores the key under OPENAI_API_KEY (or a custom env var name)
169
+ return auth?.OPENAI_API_KEY ?? null
170
+ } catch {
171
+ LOG.debug(`Failed loading Codex auth from`, local(auth_json))
172
+ return null
173
+ }
174
+ }
175
+
176
+ function fromOpencodeOpenAI() {
177
+ if ("cached" in fromOpencodeOpenAI) return fromOpencodeOpenAI.cached
178
+ const opencode_json = path.join(HOME, ".config/opencode/opencode.json")
179
+ try {
180
+ let conf = JSON.parse(fs.readFileSync(opencode_json, "utf8"))
181
+ LOG.debug(`Loaded OpenCode settings from`, local(opencode_json))
182
+ // https://opencode.ai/config.json
183
+ let o = conf?.provider?.openai?.options
184
+ if (!o) return (fromOpencodeOpenAI.cached = null)
185
+ let any,
186
+ config = {}
187
+ if ((any = o.baseURL ?? o.baseURL)) config.baseURL = any
188
+ if ((any = o.apiKey)) config.apiKey = any
189
+ if ((any = conf?.model)) config.model = any.replace("openai/", "")
190
+ fromOpencodeOpenAI.cached = Object.keys(config).length ? config : null
191
+ LOG.debug(
192
+ `Loaded config from`,
193
+ local(opencode_json),
194
+ ":",
195
+ sanitized(fromOpencodeOpenAI.cached || {}),
196
+ )
197
+ } catch {
198
+ LOG.debug(`Failed loading OpenCode settings from`, local(opencode_json))
199
+ fromOpencodeOpenAI.cached = null
200
+ }
201
+ return fromOpencodeOpenAI.cached
202
+ }
203
+
91
204
  const sanitized = ({ apiKey, ...rest }) => ({
92
205
  ...rest,
93
206
  apiKey: apiKey ? "***" : undefined,
@@ -5,6 +5,7 @@ import {
5
5
  createEvalRun,
6
6
  closeEvalRun,
7
7
  logMlflowMetrics,
8
+ logMlflowRunMetadata,
8
9
  } from "../telemetry/mlflow/evaluation.js"
9
10
  import { flushMlflowTraces } from "../telemetry/mlflow/tracing.js"
10
11
 
@@ -23,6 +24,9 @@ export function evalRun(opts = {}) {
23
24
  runId,
24
25
  mlflowRunId,
25
26
  validationsByTask: new Map(),
27
+ metricKeys: new Set(),
28
+ mlflowMetadataLogged: false,
29
+ prompts: [],
26
30
  }
27
31
  }
28
32
 
@@ -34,15 +38,22 @@ export function evalRun(opts = {}) {
34
38
  })
35
39
 
36
40
  if (typeof afterEach === "function") {
37
- afterEach(async () => {
38
- if (state) await _flushValidations(state)
41
+ afterEach(async (testState) => {
42
+ if (state) {
43
+ await _flushValidations(state)
44
+ // Report test failure/success, so aggregated output_correctness respects static asserts
45
+ _addValidation(
46
+ { _evalState: state, taskId: "code_asserts" },
47
+ testState.task.result.state === "pass",
48
+ )
49
+ }
39
50
  })
40
51
  }
41
52
 
42
53
  afterAll(async () => {
43
54
  if (state) await _flushValidations(state)
44
55
  await flushMlflowTraces()
45
- await closeEvalRun(state?.mlflowRunId).catch(() => {})
56
+ await closeEvalRun(state).catch(() => {})
46
57
  if (cds._activeEvalRun === state) cds._activeEvalRun = null
47
58
  state = null
48
59
  })
@@ -148,5 +159,40 @@ async function _postAssessmentScore(result, score, comment, config) {
148
159
  export async function logMlflowMetricsForResult(result, state = null) {
149
160
  state = state ?? cds._activeEvalRun
150
161
  if (!state?.mlflowRunId) return
151
- await logMlflowMetrics(state.mlflowRunId, result.metrics).catch(() => {})
162
+ state.prompts = state.prompts.concat(_extractPrompts(result?.spans))
163
+ await _logMlflowRunMetadataOnce(result, state)
164
+ await logMlflowMetrics(state, result.metrics).catch(() => {})
165
+ }
166
+
167
+ // REVISIT: Properly log models as Registered Models in an eval Run to cover the change that an eval run contains multiple models
168
+ async function _logMlflowRunMetadataOnce(result, state) {
169
+ if (state.mlflowMetadataLogged) return
170
+ const metadata = _extractMlflowRunMetadata(result?.spans)
171
+ if (!metadata) return
172
+ state.mlflowMetadataLogged = true
173
+ await logMlflowRunMetadata(state.mlflowRunId, metadata).catch(() => {})
174
+ }
175
+
176
+ function _extractMlflowRunMetadata(spans) {
177
+ const attrs = spans?.find(
178
+ (span) => span.attributes?.["gen_ai.operation.name"] === "chat",
179
+ )?.attributes
180
+ if (!attrs) return null
181
+
182
+ const model = attrs["gen_ai.response.model"]
183
+ const provider = attrs["gen_ai.provider.name"]
184
+ const params =
185
+ attrs["gen_ai.request.model_params"] && JSON.parse(attrs["gen_ai.request.model_params"])
186
+
187
+ if (!model && !provider && !Object.keys(params).length) return null
188
+ return { model, provider, params }
189
+ }
190
+
191
+ function _extractPrompts(spans) {
192
+ const attrs = spans?.find(
193
+ (span) => span.attributes?.["gen_ai.operation.name"] === "invoke_agent",
194
+ )?.attributes
195
+ if (!attrs) return []
196
+ const prompts = JSON.parse(attrs["mlflow.traceTag.mlflow.linkedPrompts"]) ?? []
197
+ return prompts
152
198
  }
@@ -1,5 +1,6 @@
1
1
  import cds from "@sap/cds"
2
2
  import { generatePseudonymTag } from "../store.js"
3
+ import { circuitBreaker, retry } from "../../utils/resilience.js"
3
4
 
4
5
  const LOG = cds.log("agents")
5
6
 
@@ -13,20 +14,27 @@ const DPP_CATEGORIES = new Map([
13
14
  ["URI:IP", "ip"],
14
15
  ])
15
16
 
16
- let scriptServerActive = true
17
+ const PAL_CONNECTIVITY_ERROR = "73003604"
18
+ const retryMw = retry(5, {
19
+ shouldRetry: (err) => String(err?.message ?? "").includes(PAL_CONNECTIVITY_ERROR),
20
+ })
21
+ const circuitBreakerMw = circuitBreaker()
17
22
 
18
23
  export async function anonymize(text, seed) {
19
- if (!scriptServerActive || !text || !seed) return { text }
24
+ if (!text || !seed) return { text }
20
25
  try {
21
- return await pseudonymizeText(text, seed)
26
+ const resilientPseudonymize = circuitBreakerMw({
27
+ fn: retryMw({ fn: pseudonymizeText }),
28
+ context: { uri: "hana-script-server" },
29
+ })
30
+ return await resilientPseudonymize({ text, seed })
22
31
  } catch (err) {
23
32
  LOG.error(`SAP HANA Cloud NLS based pseudonymization disabled due to error: `, err)
24
- scriptServerActive = false
25
33
  return { text }
26
34
  }
27
35
  }
28
36
 
29
- async function pseudonymizeText(text, seed) {
37
+ async function pseudonymizeText({ text, seed }) {
30
38
  const res = await cds.run(buildPalCallSql(), [text])
31
39
  const entities = res.changes[1]
32
40
  const piiEntities = entities
@@ -11,6 +11,11 @@ import {
11
11
  withPromptCachingOptions,
12
12
  withPromptCachingParams,
13
13
  } from "../utils/caching.js"
14
+ import {
15
+ isAdditiveCacheUsageModel,
16
+ normalizeAdditiveCacheUsage,
17
+ normalizeAdditiveCacheUsageChunk,
18
+ } from "../utils/usage.js"
14
19
  import { syncSystemPrompt } from "../telemetry/mlflow/prompts.js"
15
20
 
16
21
  const LOG = cds.log("agents")
@@ -29,8 +34,12 @@ function noTemp0Support(model) {
29
34
  }
30
35
 
31
36
  const llmError = (err) => {
32
- LOG.error(`AI Core request failed!`, err.rootCause || err)
33
- cds.error(cds.i18n.messages.at("LLM_UNAVAILABLE"))
37
+ if (err.rootCause?.message.match(/Could not find service credentials for AI Core/)) {
38
+ throw err.rootCause
39
+ } else {
40
+ LOG.error(`AI Core request failed!`, err.rootCause || err)
41
+ cds.error(cds.i18n.messages.at("LLM_UNAVAILABLE"))
42
+ }
34
43
  }
35
44
 
36
45
  class _InstrumentedOrchestrationClient extends OrchestrationClient {
@@ -83,6 +92,8 @@ class _InstrumentedOrchestrationClient extends OrchestrationClient {
83
92
  // works for every agent regardless of this flag (deep or managed alike).
84
93
  // Default on; `streaming: false` opts out.
85
94
  streaming: streaming !== false,
95
+ // p-retry from langchain = 0, retry for AI Core completion is still set to 2
96
+ maxRetries: 0,
86
97
  onFailedAttempt: (err) => {
87
98
  // Abort retries when circuit breaker is open (otherwise pRetry delays ~30-60s)
88
99
  if (err.code === "EOPENBREAKER" || err.message === "Breaker is open") {
@@ -108,7 +119,8 @@ class _InstrumentedOrchestrationClient extends OrchestrationClient {
108
119
  opts = _withMiddleware(this, opts)
109
120
  const prepared = _prepareMessages(messages, { flatten, model, opts })
110
121
  try {
111
- return super._generate(prepared.inputMessages, prepared.opts, runManager)
122
+ const result = await super._generate(prepared.inputMessages, prepared.opts, runManager)
123
+ return isAdditiveCacheUsageModel(model) ? normalizeAdditiveCacheUsage(result) : result
112
124
  } catch (err) {
113
125
  llmError(err)
114
126
  }
@@ -135,6 +147,7 @@ class _InstrumentedOrchestrationClient extends OrchestrationClient {
135
147
  let turnHasToolCall = false
136
148
  try {
137
149
  for await (const chunk of super._streamResponseChunks(inputMessages, opts, runManager)) {
150
+ if (isAdditiveCacheUsageModel(model)) normalizeAdditiveCacheUsageChunk(chunk)
138
151
  if (chunk.message?.tool_call_chunks?.length > 0) turnHasToolCall = true
139
152
  const text = chunk.text || extractTextFromContentBlocks(chunk)
140
153
  const reasoning = extractReasoningFromChunk(chunk)
@@ -0,0 +1,9 @@
1
+ import { ChatOpenAI } from "@langchain/openai"
2
+
3
+ export default class ChatOpenAIService extends ChatOpenAI {
4
+ constructor(name, options = {}) {
5
+ const { credentials = {} } = options
6
+ super({ ...options, configuration: credentials })
7
+ this.name = name
8
+ }
9
+ }