@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.
- package/_i18n/messages.properties +2 -0
- package/cds-plugin.js +13 -10
- package/lib/agents/middleware/content-filter.js +4 -0
- package/lib/agents/middleware/hitl-decision-note-injector.js +18 -6
- package/lib/agents/middleware/hitl.js +17 -6
- package/lib/agents/middleware/index.js +1 -1
- package/lib/agents/middleware/masking.js +6 -3
- package/lib/agents/middleware/remote-mcp.js +5 -2
- package/lib/agents/middleware/status-update.js +209 -76
- package/lib/agents/middleware/tool-wrap.js +1 -2
- package/lib/agents/summarize-on-timeout.js +5 -2
- package/lib/config/local.js +121 -8
- package/lib/eval/eval-run.js +50 -4
- package/lib/masking/unstructured/hana.js +13 -5
- package/lib/models/aicore.js +16 -3
- package/lib/models/openai.js +9 -0
- package/lib/preview/chat.html +561 -93
- package/lib/telemetry/chat-tracing.js +2 -0
- package/lib/telemetry/mlflow/evaluation.js +83 -4
- package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +39 -5
- package/lib/telemetry/mlflow/exporter/MlflowExporter.js +43 -4
- package/lib/telemetry/mlflow/index.js +1 -7
- package/lib/telemetry/mlflow/prompts.js +15 -11
- package/lib/utils/markdown.js +1 -8
- package/lib/utils/resilience.js +2 -1
- package/lib/utils/toml.js +67 -0
- package/lib/utils/usage.js +34 -0
- package/lib/utils/utils.js +43 -1
- package/package.json +31 -26
- package/srv/handlers/graph-executor/crash-handler.js +47 -0
- package/srv/handlers/graph-executor/hitl.js +27 -0
- package/srv/handlers/graph-executor.js +19 -5
- package/srv/handlers/index.js +6 -1
- package/srv/handlers/mcp-tools.js +11 -2
- package/srv/handlers/subagent-tools.js +13 -4
- package/srv/handlers/system-prompt.js +30 -27
- package/srv/handlers/tools.js +14 -13
package/lib/config/local.js
CHANGED
|
@@ -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
|
-
*
|
|
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 =
|
|
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
|
|
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 =
|
|
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,
|
package/lib/eval/eval-run.js
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
24
|
+
if (!text || !seed) return { text }
|
|
20
25
|
try {
|
|
21
|
-
|
|
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
|
package/lib/models/aicore.js
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
+
}
|