@cap-js/agents 0.9.4 → 0.9.6

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.
@@ -1,14 +1,24 @@
1
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
2
3
  agent_status_calling_tools = Calling {0}
4
+ #XMSG Status info message send to the user client when an entity is being queried. {0} = entity name
3
5
  agent_status_querying = Querying {0}
6
+ #XMSG Status info message send to the user client after a tool result returned and the agent is processing it
4
7
  agent_status_processing_response = Processing tool response
8
+ #XMSG Status info message send to the user client after multiple tool results returned and the agent is processing it
5
9
  agent_status_processing_responses = Processing tool responses
10
+ agent_status_summarizing_progress = Agent interrupted. Summarising progress.
11
+ AGENT_SUMMARY_QUOTA_FALLBACK = The task was stopped as it reached its resource consumption limit. Some work may have been done — please check the results or retry with a simpler request.
12
+ AGENT_SUMMARY_TIMEOUT_FALLBACK = The task was stopped as it reached its time limit. Progress may be incomplete. Continue running or stop?
6
13
 
7
14
  # Authentication
15
+ #XMSG Error message for HTTP 401 status
8
16
  UNAUTHORIZED = Unauthorized
17
+ #XMSG Error message for HTTP 403 status
9
18
  FORBIDDEN = Forbidden
10
19
 
11
20
  # Input validation
21
+ #XMSG Error message send to the users client when their message exceeds the limit. {0} = number of allowed characters
12
22
  MESSAGE_TOO_LONG = The message must not exceed {0} characters.
13
23
 
14
24
  # Quota enforcement
@@ -28,4 +38,11 @@ CONTENT_FILTER_TOOL_BLOCKED = The last tool called yielded a malicious result. P
28
38
  PUSH_URL_NOT_ALLOWED = Push notification callback URL ''{0}'' not in allowed domains: {1}
29
39
 
30
40
  # HITL
41
+ HITL_APPROVE = Approve
42
+ HITL_REJECT = Reject
43
+ HITL_CONTINUE = Continue
44
+ HITL_STOP = Stop
31
45
  RESUME_REQUIRES_TEXT = Resume message must contain text (e.g. ''approve'' or ''reject'') or a data part.
46
+
47
+ # LLM
48
+ LLM_UNAVAILABLE = The agent is temporarily unavailable. Please try again later.
package/cds-plugin.js CHANGED
@@ -1,37 +1,21 @@
1
1
  import cds from "@sap/cds"
2
2
 
3
3
  const LOG = cds.log("agents")
4
- import { patchLangChain } from "./lib/telemetry/tracing.js"
5
- import cds_compile_to_a2a from "./lib/compile.js"
6
4
  import registerDefaultAgentHandlers from "./srv/handlers/index.js"
5
+ import { patchLangChain } from "./lib/telemetry/tracing.js"
7
6
  import { slugified } from "./lib/utils/utils.js"
8
-
7
+ import cds_compile_to_a2a from "./lib/compile.js"
9
8
  cds.compile.to.a2a = cds_compile_to_a2a
10
9
 
11
- // Detect optional peer plugins (@cap-js/telemetry, @cap-js/audit-logging)
12
- const hasTelemetry = !!cds.env.requires?.telemetry
13
- const hasAuditLog = !!cds.env.requires?.["audit-log"]
14
- const hasMetrics = !!cds.env.requires?.telemetry?.["metrics"]
15
- const hasTracing = !!cds.env.requires?.telemetry?.["tracing"]
16
- if (!hasTelemetry && cds.env.profiles?.includes("production"))
17
- LOG.warn("@cap-js/telemetry not configured - metrics and tracing disabled")
18
- if (!hasAuditLog && cds.env.profiles?.includes("production"))
19
- LOG.warn("@cap-js/audit-logging not configured - audit events disabled")
20
- if (hasTelemetry && !hasMetrics && cds.env.profiles?.includes("production"))
21
- LOG.warn("@cap-js/telemetry has no metrics configured - metrics disabled")
22
- if (hasTelemetry && !hasTracing && cds.env.profiles?.includes("production"))
23
- LOG.warn("@cap-js/telemetry has no tracing configured - tracing disabled")
24
-
25
10
  // Enable doc comments in CSN for agent card generation
26
11
  cds.env.cdsc = { ...cds.env.cdsc, docComment: true }
27
12
 
28
- // Ensure A2A correlation fields are indexed by SAP Cloud Logging
29
- cds.env.log ??= {}
30
- const cls_fields = (cds.env.log.cls_custom_fields ??= [])
31
- if (!cls_fields.includes("agent.task.id")) cls_fields.push("agent.task.id")
32
- if (!cls_fields.includes("agent.context.id")) cls_fields.push("agent.context.id")
33
-
34
13
  cds.on("bootstrap", (app) => {
14
+ // Ensure A2A correlation fields are indexed by SAP Cloud Logging
15
+ const cls_fields = ((cds.env.log ??= {}).cls_custom_fields ??= [])
16
+ if (!cls_fields.includes("agent.task.id")) cls_fields.push("agent.task.id")
17
+ if (!cls_fields.includes("agent.context.id")) cls_fields.push("agent.context.id")
18
+
35
19
  const providers = {
36
20
  ["llm-mock"]: {},
37
21
  anthropic: {
@@ -77,64 +61,97 @@ cds.on("bootstrap", (app) => {
77
61
  })
78
62
  })
79
63
 
80
- cds.on("serving", (srv) => {
81
- if (!(srv instanceof cds.ApplicationService)) return
82
- if (!srv.definition?.["@agent"]) return
83
- registerDefaultAgentHandlers(srv)
84
- })
64
+ !(function cds_agents_config_compat() {
65
+ // Also support legacy `cds.agents.pool` configuration by merging it into `cds.agents.quotas`
66
+ if (cds.env.agents?.pool)
67
+ cds.env.agents.quotas = { ...cds.env.agents.quotas, ...cds.env.agents.pool }
68
+ })()
85
69
 
86
- // Schedule active_users metric computation + MLflow exporter
87
- cds.on("served", async () => {
88
- if (hasTelemetry) {
89
- const { setupActiveUsersMetric } = await import("./lib/telemetry/active-users.js")
90
- setupActiveUsersMetric()
91
- // Defer LangChain patching so the CDS model is fully loaded before patches land.
92
- // opt-out via cds.env.agents.trace_langchain = false
93
- if (cds.env.agents?.trace_langchain !== false) {
94
- await patchLangChain()
70
+ !(function cds_requires_llm() {
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
+ ) {
78
+ 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)
95
82
  }
96
- }
97
83
 
98
- if (cds.env.agents?.mlflow) {
99
- const { setupMlflowExporter } = await import("./lib/telemetry/mlflow/index.js")
100
- setupMlflowExporter()
101
- }
102
- })
84
+ const config = cds.requires.llm,
85
+ credentials = {}
86
+ const { url, destination, anthropicApiUrl, apiKey } = config?.credentials || {}
87
+ if (url) credentials.url = url
88
+ if (destination) credentials.destination = destination
89
+ if (anthropicApiUrl) credentials.anthropicApiUrl = anthropicApiUrl
90
+ if (apiKey) credentials.apiKey = "***"
91
+ LOG.info(`cds.connect.to 'llm' with:`, { ...config, credentials })
92
+ })
93
+ })()
94
+
95
+ !(function add_agent_handlers() {
96
+ cds.on("serving", (srv) => {
97
+ if (srv.definition?.protocols?.agent) registerDefaultAgentHandlers(srv)
98
+ })
99
+ })()
100
+
101
+ if (cds.env.requires?.telemetry)
102
+ !(function telemetry() {
103
+ // Schedule active_users metric computation + MLflow exporter
104
+ cds.on("served", async () => {
105
+ const { setupActiveUsersMetric } = await import("./lib/telemetry/active-users.js")
106
+ setupActiveUsersMetric()
107
+ // Defer LangChain patching so the CDS model is fully loaded before patches land.
108
+ // opt-out via cds.env.agents.trace_langchain = false
109
+ if (cds.env.agents?.trace_langchain !== false) {
110
+ await patchLangChain()
111
+ }
112
+
113
+ if (cds.env.agents?.mlflow) {
114
+ const { setupMlflowExporter } = await import("./lib/telemetry/mlflow/index.js")
115
+ setupMlflowExporter()
116
+ }
117
+ })
118
+ })()
103
119
 
104
120
  // Bootstrap sidecar mode when the agent-sidecar profile is active
105
- if (cds.env.profiles?.includes("agent-sidecar")) {
106
- // Auto-mark @agent services as external so CDS does not serve them locally.
107
- // Auto-mark them as hcql services served externally, they are served from the main app.
108
- // This runs after model load but before cds.serve() filters definitions,
109
- // so users don't need to add these things manually.
110
- cds.on("loaded", (csn) => {
111
- const hcql = cds.requires.kinds["hcql"]
112
- const agentSidecar = cds.requires.agent || {}
113
- const hcqlBase = agentSidecar.url // For local development the base URL is given in the package.json
114
- const agentCredentials = agentSidecar.credentials || {}
115
- for (const [name, def] of Object.entries(csn.definitions || {})) {
116
- if (def.kind !== "service") continue
117
- if (!def["@agent"]) continue
118
- // Mark as external so CDS does not serve it locally — it will be served via HCQL from the main app.
119
- def["@cds.external"] = true
120
- // Java main apps use the CDS service name in the HCQL path (/hcql/CatalogService),
121
- // Node.js main apps use the slugified path (/hcql/catalog).
122
- const isJava = !cds.env.profiles?.includes("node")
123
- let n = isJava ? name : slugified(name)
124
- if (cds.requires[name]) continue // skip if user provided service-specific config for this service
125
- if (cds.requires[n]) continue // skip if user provided service-specific config for possibly the slugified version
126
- const newRequiresEntry = { ...hcql, kind: "hcql" }
127
- newRequiresEntry.credentials = {
128
- ...agentCredentials,
129
- ...(hcqlBase && { url: `${hcqlBase}/${n.split(".").pop()}` }),
130
- ...(agentCredentials.destination && { path: `/${n.split(".").pop()}` }),
121
+ if (cds.env.profiles?.includes("agent-sidecar"))
122
+ !(function sidecar() {
123
+ // Auto-mark @agent services as external so CDS does not serve them locally.
124
+ // Auto-mark them as hcql services served externally, they are served from the main app.
125
+ // This runs after model load but before cds.serve() filters definitions,
126
+ // so users don't need to add these things manually.
127
+ cds.on("loaded", (csn) => {
128
+ const hcql = cds.requires.kinds["hcql"]
129
+ const agentSidecar = cds.requires.agent || {}
130
+ const hcqlBase = agentSidecar.url // For local development the base URL is given in the package.json
131
+ const agentCredentials = agentSidecar.credentials || {}
132
+ for (const [name, def] of Object.entries(csn.definitions || {})) {
133
+ if (def.kind !== "service") continue
134
+ if (!def["@agent"]) continue
135
+ // Mark as external so CDS does not serve it locally — it will be served via HCQL from the main app.
136
+ def["@cds.external"] = true
137
+ // Java main apps use the CDS service name in the HCQL path (/hcql/CatalogService),
138
+ // Node.js main apps use the slugified path (/hcql/catalog).
139
+ const isJava = !cds.env.profiles?.includes("node")
140
+ let n = isJava ? name : slugified(name)
141
+ if (cds.requires[name]) continue // skip if user provided service-specific config for this service
142
+ if (cds.requires[n]) continue // skip if user provided service-specific config for possibly the slugified version
143
+ const newRequiresEntry = { ...hcql, kind: "hcql" }
144
+ newRequiresEntry.credentials = {
145
+ ...agentCredentials,
146
+ ...(hcqlBase && { url: `${hcqlBase}/${n.split(".").pop()}` }),
147
+ ...(agentCredentials.destination && { path: `/${n.split(".").pop()}` }),
148
+ }
149
+ cds.requires[n] = newRequiresEntry
131
150
  }
132
- cds.requires[n] = newRequiresEntry
133
- }
134
- })
151
+ })
135
152
 
136
- cds.on("served", async () => {
137
- const { bootstrapSidecar } = await import("./lib/sidecar.js")
138
- await bootstrapSidecar()
139
- })
140
- }
153
+ cds.on("served", async () => {
154
+ const { bootstrapSidecar } = await import("./lib/sidecar.js")
155
+ await bootstrapSidecar()
156
+ })
157
+ })()
@@ -27,7 +27,7 @@ export async function createAutoDeepAgent(srv, agentDir) {
27
27
  createSkillsMiddleware,
28
28
  } = await import("deepagents")
29
29
 
30
- LOG.debug("Auto-building deep agent", {
30
+ LOG.debug("Auto-building deep agent for:", {
31
31
  service: srv?.name,
32
32
  agentDir,
33
33
  tools: tools.length,
@@ -25,6 +25,6 @@ export default async function buildMiddleware(srv, options = {}) {
25
25
  await statusUpdateMiddleware(),
26
26
  ...(await humanInTheLoopMiddleware(srv, tools)),
27
27
  toolSelectionMiddleware(),
28
- toolWrapMiddleware(),
28
+ toolWrapMiddleware(srv),
29
29
  ].filter(Boolean)
30
30
  }
@@ -6,7 +6,7 @@ import { z } from "zod"
6
6
 
7
7
  /**
8
8
  * Quota enforcement middleware for deep agents.
9
- * Reads limits from cds.env.agents.pool at check time (not creation time).
9
+ * Reads limits from cds.env.agents.quotas at check time (not creation time).
10
10
  * Returns array to spread into middleware config.
11
11
  */
12
12
  export async function quotaEnforcerMiddleware() {
@@ -21,12 +21,12 @@ export async function quotaEnforcerMiddleware() {
21
21
  afterModel: {
22
22
  canJumpTo: ["end"],
23
23
  hook: (state) => {
24
- const pool = cds.env.agents?.pool || {}
24
+ const quotas = cds.env.agents?.quotas || {}
25
25
  const newCallCount = state.runModelCallCount + 1
26
26
 
27
27
  // Check LLM invocation limit
28
- if (pool.maxLLMInvocationsPerTask && newCallCount > pool.maxLLMInvocationsPerTask) {
29
- const reason = `LLM call limit exceeded: ${newCallCount} calls (max ${pool.maxLLMInvocationsPerTask} per task)`
28
+ if (quotas.maxLLMInvocationsPerTask && newCallCount > quotas.maxLLMInvocationsPerTask) {
29
+ const reason = `LLM call limit exceeded: ${newCallCount} calls (max ${quotas.maxLLMInvocationsPerTask} per task)`
30
30
  audit("QuotaExceeded", {
31
31
  data: {
32
32
  service: cds.context?.["agent.service"],
@@ -47,8 +47,8 @@ export async function quotaEnforcerMiddleware() {
47
47
  const newTokenCount = state.runTokenCount + consumed
48
48
 
49
49
  // Check token limit
50
- if (pool.maxLLMTokensPerTask && newTokenCount > pool.maxLLMTokensPerTask) {
51
- const reason = `Token limit exceeded: ${newTokenCount} tokens (max ${pool.maxLLMTokensPerTask} per task)`
50
+ if (quotas.maxLLMTokensPerTask && newTokenCount > quotas.maxLLMTokensPerTask) {
51
+ const reason = `Token limit exceeded: ${newTokenCount} tokens (max ${quotas.maxLLMTokensPerTask} per task)`
52
52
  audit("QuotaExceeded", {
53
53
  data: {
54
54
  service: cds.context?.["agent.service"],
@@ -67,8 +67,8 @@ export async function quotaEnforcerMiddleware() {
67
67
  const newToolCallCount = state.runToolCallCount + toolCalls
68
68
 
69
69
  // Check tool call limit
70
- if (pool.maxToolCallsPerTask && newToolCallCount > pool.maxToolCallsPerTask) {
71
- const reason = `Tool call limit exceeded: ${newToolCallCount} calls (max ${pool.maxToolCallsPerTask} per task)`
70
+ if (quotas.maxToolCallsPerTask && newToolCallCount > quotas.maxToolCallsPerTask) {
71
+ const reason = `Tool call limit exceeded: ${newToolCallCount} calls (max ${quotas.maxToolCallsPerTask} per task)`
72
72
  audit("QuotaExceeded", {
73
73
  data: {
74
74
  service: cds.context?.["agent.service"],
@@ -19,13 +19,14 @@ export function remoteMcpMiddleware() {
19
19
  const cache = (cds.context.__mcpDynamicTools ??= {})
20
20
  const placeholders = (request.tools ?? []).filter((t) => t._mcpDynamic)
21
21
  await Promise.all(
22
- placeholders.map(async ({ mcpUrl, serviceName, resolveHeaders }) => {
22
+ placeholders.map(async ({ mcpUrl, serviceName, resolveHeaders, toolFilter }) => {
23
23
  if (cache[mcpUrl]) return
24
24
  const headers = await resolveHeaders()
25
25
  const client = new MultiServerMCPClient({
26
26
  mcpServers: { default: { transport: "http", url: mcpUrl, headers } },
27
27
  })
28
- const raw = await client.getTools()
28
+ let raw = await client.getTools()
29
+ if (toolFilter?.length) raw = raw.filter((t) => toolFilter.includes(t.name))
29
30
  for (const t of raw) t.name = toolName(`${serviceName}_${t.name}`)
30
31
  cache[mcpUrl] = raw
31
32
  LOG.debug(
@@ -63,7 +63,7 @@ function resolveToolLabel(tc) {
63
63
  /**
64
64
  * Publishes a non-final "working" status-update to the eventBus.
65
65
  */
66
- function publishStatus(text) {
66
+ export function publishStatus(text) {
67
67
  const eventBus = cds.context?.["agent.eventBus"]
68
68
  if (!eventBus || !text) return
69
69
 
@@ -10,13 +10,13 @@ const LOG = cds.log("agents")
10
10
  * Handles two paths: thrown errors (err.details appended when present)
11
11
  * and tools returning artifact.isError=true (@cap-js/mcp action pattern).
12
12
  */
13
- export function toolWrapMiddleware() {
13
+ export function toolWrapMiddleware(srv) {
14
14
  return createMiddleware({
15
15
  name: "ToolWrapMiddleware",
16
- wrapToolCall: async (request, handler) => {
16
+ wrapToolCall: async function (request, handler) {
17
17
  const { name, id, args } = request.toolCall
18
18
  try {
19
- LOG.debug("[tool]", name, args)
19
+ LOG.debug(srv.name, "calling tool", name, args)
20
20
  const result = await handler(request)
21
21
  if (ToolMessage.isInstance(result) && result.artifact?.isError === true) {
22
22
  result.status = "error"
@@ -29,12 +29,11 @@ export function toolWrapMiddleware() {
29
29
  ) {
30
30
  result.status = "error"
31
31
  }
32
- if (result?.status === "error") LOG.debug("[tool] error", name, result.content)
33
- else LOG.debug("[tool] completed", name)
32
+ if (result?.status === "error") LOG.debug(srv.name, "tool error", name, result.content)
34
33
  return result
35
34
  } catch (err) {
36
35
  if (isGraphInterrupt(err)) throw err
37
- LOG.debug("[tool] error", name, err)
36
+ LOG.debug(srv.name, "tool error", name, err)
38
37
  let content = `Error: ${err.message}`
39
38
  if (Array.isArray(err.details) && err.details.length > 0) {
40
39
  const lines = err.details.map((d) => `- ${d.message}`).join("\n")
@@ -24,11 +24,9 @@ function secondsUntilMidnightUTC() {
24
24
  * Returns null if within limits, or { message, retryAfter } if a limit is breached.
25
25
  */
26
26
  export default async function quotaEnforcerAtStart() {
27
- const pool = cds.env.agents?.pool
28
- if (!pool) {
29
- LOG.debug(
30
- "No quota pool configuration found at cds.env.agents.pool — quota enforcement disabled",
31
- )
27
+ const quotas = cds.env.agents?.quotas
28
+ if (!quotas) {
29
+ LOG.debug("No quota configuration found at cds.env.agents.quotas — quota enforcement disabled")
32
30
  return null
33
31
  }
34
32
 
@@ -65,44 +63,49 @@ export default async function quotaEnforcerAtStart() {
65
63
  .columns("coalesce(sum(usageLlmTokens),0) as llmTokensThisDay")
66
64
  .where({ createdAt: { ">=": today.toISOString() } }),
67
65
  ])
68
- if (pool.maxConcurrentTasks != null && concurrentTasks >= pool.maxConcurrentTasks) {
66
+ if (quotas.maxConcurrentTasks != null && concurrentTasks >= quotas.maxConcurrentTasks) {
69
67
  return {
70
- message: cds.i18n.messages.at("QUOTA_CONCURRENT_TASKS", [pool.maxConcurrentTasks]),
68
+ message: cds.i18n.messages.at("QUOTA_CONCURRENT_TASKS", [quotas.maxConcurrentTasks]),
71
69
  retryAfter: 30,
72
70
  }
73
71
  }
74
72
  if (
75
- pool.maxConcurrentTasksPerUser != null &&
76
- concurrentTasksThisUser >= pool.maxConcurrentTasksPerUser
73
+ quotas.maxConcurrentTasksPerUser != null &&
74
+ concurrentTasksThisUser >= quotas.maxConcurrentTasksPerUser
77
75
  ) {
78
76
  return {
79
77
  message: cds.i18n.messages.at("QUOTA_CONCURRENT_TASKS_PER_USER", [
80
- pool.maxConcurrentTasksPerUser,
78
+ quotas.maxConcurrentTasksPerUser,
81
79
  ]),
82
80
  retryAfter: 30,
83
81
  }
84
82
  }
85
- if (pool.maxTasksPerHour != null && lastHourTasks >= pool.maxTasksPerHour) {
83
+ if (quotas.maxTasksPerHour != null && lastHourTasks >= quotas.maxTasksPerHour) {
86
84
  return {
87
- message: cds.i18n.messages.at("QUOTA_TASKS_PER_HOUR", [pool.maxTasksPerHour]),
85
+ message: cds.i18n.messages.at("QUOTA_TASKS_PER_HOUR", [quotas.maxTasksPerHour]),
88
86
  retryAfter: secondsUntilNextHour(),
89
87
  }
90
88
  }
91
- if (pool.maxTasksPerHourPerUser != null && lastHourTasksThisUser >= pool.maxTasksPerHourPerUser) {
89
+ if (
90
+ quotas.maxTasksPerHourPerUser != null &&
91
+ lastHourTasksThisUser >= quotas.maxTasksPerHourPerUser
92
+ ) {
92
93
  return {
93
- message: cds.i18n.messages.at("QUOTA_TASKS_PER_HOUR_PER_USER", [pool.maxTasksPerHourPerUser]),
94
+ message: cds.i18n.messages.at("QUOTA_TASKS_PER_HOUR_PER_USER", [
95
+ quotas.maxTasksPerHourPerUser,
96
+ ]),
94
97
  retryAfter: secondsUntilNextHour(),
95
98
  }
96
99
  }
97
- if (pool.maxToolCallsPerHour != null && lastHourToolCalls >= pool.maxToolCallsPerHour) {
100
+ if (quotas.maxToolCallsPerHour != null && lastHourToolCalls >= quotas.maxToolCallsPerHour) {
98
101
  return {
99
- message: cds.i18n.messages.at("QUOTA_TOOL_CALLS_PER_HOUR", [pool.maxToolCallsPerHour]),
102
+ message: cds.i18n.messages.at("QUOTA_TOOL_CALLS_PER_HOUR", [quotas.maxToolCallsPerHour]),
100
103
  retryAfter: secondsUntilNextHour(),
101
104
  }
102
105
  }
103
- if (pool.maxLLMTokensPerDay != null && llmTokensThisDay >= pool.maxLLMTokensPerDay) {
106
+ if (quotas.maxLLMTokensPerDay != null && llmTokensThisDay >= quotas.maxLLMTokensPerDay) {
104
107
  return {
105
- message: cds.i18n.messages.at("QUOTA_LLM_TOKENS_PER_DAY", [pool.maxLLMTokensPerDay]),
108
+ message: cds.i18n.messages.at("QUOTA_LLM_TOKENS_PER_DAY", [quotas.maxLLMTokensPerDay]),
106
109
  retryAfter: secondsUntilMidnightUTC(),
107
110
  }
108
111
  }
@@ -1,10 +1,11 @@
1
1
  import cds from "@sap/cds"
2
2
  import { HumanMessage } from "@langchain/core/messages"
3
3
  import { short } from "../utils/utils.js"
4
+ import { publishStatus } from "./middleware/status-update.js"
4
5
 
5
6
  const LOG = cds.log("agents")
6
7
 
7
- const DEFAULT_TIMEOUT = 10_000
8
+ const DEFAULT_TIMEOUT = 20_000
8
9
 
9
10
  /**
10
11
  * Summarize partial work from a graph execution that was interrupted
@@ -17,10 +18,10 @@ const DEFAULT_TIMEOUT = 10_000
17
18
  * @param {object} options
18
19
  * @param {string} options.contextId
19
20
  * @param {string} options.serviceName
20
- * @param {string} options.reason - Why execution was interrupted (e.g. "timed out", "quota exceeded")
21
+ * @param {string} options.reason - "timeOut" or "quota"
21
22
  * @param {object} [options.checkpointer] - LangGraph checkpointer instance
22
23
  * @param {Function} options.getModel - Async function returning a LangChain chat model
23
- * @param {number} [options.timeout] - Max ms to spend on summarization (default 10s)
24
+ * @param {number} [options.timeout] - Max ms to spend on summarization (default 20s)
24
25
  * @returns {Promise<string>} Summary message or fallback
25
26
  */
26
27
  export async function summarizePartialWork({
@@ -31,7 +32,10 @@ export async function summarizePartialWork({
31
32
  getModel,
32
33
  timeout = DEFAULT_TIMEOUT,
33
34
  }) {
34
- const fallback = `The task was stopped (${reason}). Some work may have been done — please check the results or retry with a simpler request.`
35
+ publishStatus(cds.i18n.messages.at(`agent_status_summarizing_progress`))
36
+ const fallback = cds.i18n.messages.at(
37
+ reason === "timeOut" ? "AGENT_SUMMARY_TIMEOUT_FALLBACK" : "AGENT_SUMMARY_QUOTA_FALLBACK",
38
+ )
35
39
 
36
40
  try {
37
41
  if (!getModel) return fallback
@@ -52,10 +56,11 @@ export async function summarizePartialWork({
52
56
  return `[${role}]: ${content?.slice(0, 500)}`
53
57
  })
54
58
  .join("\n")
55
- const summaryPrompt = `The following agent task was interrupted (${reason}). Based on the conversation so far, provide a brief summary of what was accomplished and what remains. Be concise.
56
-
57
- Conversation:
58
- ${conversationSnippet}`
59
+ let summaryPrompt =
60
+ reason === "timeOut"
61
+ ? `Agent task was not completed within its time limit. Write short, precise progress summary so user can decide whether to continue. State completed work and immediate next work. Do not claim work not shown. Start with: Agent did not finish within time! End with: Continue running or stop? No other questions to the user allowed!`
62
+ : `Agent task was interrupted. Reason: ${reason}. Based on conversation history, provide brief summary of completed work and remaining work. Be concise.`
63
+ summaryPrompt += `\n\n Conversation Snippet: \n\n ${conversationSnippet.trim()}`
59
64
 
60
65
  let summaryTimer
61
66
  try {
@@ -70,19 +75,19 @@ ${conversationSnippet}`
70
75
  ])
71
76
 
72
77
  const summary =
73
- typeof response.content === "string" ? response.content : JSON.stringify(response.content)
78
+ typeof response.content === "string" ? response.content : response.content?.[0]?.text
79
+ if (!summary) throw new Error("Summary LLM returned no text")
74
80
 
75
- LOG.info("partial work summary generated", {
81
+ LOG.info(serviceName, "partial work summary generated", {
76
82
  conversation: short(contextId),
77
- service: serviceName,
78
83
  reason,
79
84
  })
80
- return `Task stopped (${reason}). Progress summary: ${summary}`
85
+ return summary
81
86
  } finally {
82
87
  clearTimeout(summaryTimer)
83
88
  }
84
89
  } catch (err) {
85
- LOG.debug("partial work summary failed, using fallback", {
90
+ LOG.debug(serviceName, "partial work summary failed, using fallback", {
86
91
  conversation: short(contextId),
87
92
  error: err.message,
88
93
  })
package/lib/compile.js CHANGED
@@ -1,10 +1,6 @@
1
1
  import cds from "@sap/cds"
2
- import {
3
- getDescription,
4
- getFilteredEntities,
5
- getFilteredActions,
6
- slugified,
7
- } from "./utils/utils.js"
2
+ import { getFilteredEntities, getFilteredActions } from "@cap-js/mcp/lib/utils/tools-shared.js"
3
+ import { getDescription, slugified } from "./utils/utils.js"
8
4
  import { buildAgentCard } from "./protocol/agent-card.js"
9
5
 
10
6
  const A2A_BASE_PATH = "/a2a"
@@ -51,9 +47,8 @@ function cds_compile_to_a2a(csn, options = {}) {
51
47
  streaming: true,
52
48
  })
53
49
 
54
- if (/^(?:obj|object)$/i.test(options.as)) return agentCard
55
-
56
- return JSON.stringify(agentCard, null, 2)
50
+ if (process.stdout.isTTY || options.as === "json") return agentCard
51
+ else return JSON.stringify(agentCard, null, 2)
57
52
  }
58
53
 
59
54
  export default cds_compile_to_a2a
@@ -0,0 +1,94 @@
1
+ import path from "node:path"
2
+ import fs from "node:fs"
3
+ import os from "node:os"
4
+ import cds from "@sap/cds"
5
+
6
+ const HOME = os.homedir() || process.env.HOME || process.env.USERPROFILE
7
+ const local = (file) => file.replace(HOME, "~")
8
+ const LOG = cds.log("agents")
9
+
10
+ /**
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
15
+ */
16
+ export function resolve_config(options) {
17
+ let config = fromEnv()
18
+ if (!config?.anthropicApiUrl)
19
+ config = {
20
+ ...config,
21
+ ...(fromClaude() || fromOpencode()),
22
+ }
23
+ let { model, ...credentials } = config
24
+ if (!model && !options?.model) return { kind: "mock" }
25
+ return {
26
+ kind: "anthropic",
27
+ model: options?.model || model,
28
+ credentials: {
29
+ ...credentials,
30
+ ...options?.credentials,
31
+ },
32
+ }
33
+ }
34
+
35
+ function fromEnv(env = process.env, silent) {
36
+ let any,
37
+ config = {}
38
+ if ((any = env.ANTHROPIC_BASE_URL)) config.anthropicApiUrl = any
39
+ if ((any = env.ANTHROPIC_AUTH_TOKEN)) config.apiKey = any
40
+ if ((any = env.ANTHROPIC_API_KEY)) config.apiKey = any
41
+ if ((any = env.ANTHROPIC_MODEL)) config.model = any
42
+ if (!Object.keys(config).length) return null
43
+ if (!silent) LOG.debug(`Loaded Anthropic settings from env:`, config)
44
+ return config
45
+ }
46
+
47
+ function fromClaude() {
48
+ if ("cached" in fromClaude) return fromClaude.cached
49
+ const settings_json = path.join(HOME, ".claude/settings.json")
50
+ try {
51
+ let settings = JSON.parse(fs.readFileSync(settings_json, "utf8"))
52
+ // https://www.schemastore.org/claude-code-settings.json
53
+
54
+ let conf = (fromClaude.cached = fromEnv(settings?.env, "silent"))
55
+ if (!conf.model && settings.env) {
56
+ let family = (settings.model || "sonnet").toUpperCase()
57
+ conf.model = settings.env[`ANTHROPIC_DEFAULT_${family}_MODEL`] || settings?.model
58
+ }
59
+ LOG.debug(`Loaded config from`, local(settings_json), ":", sanitized(conf))
60
+ } catch {
61
+ LOG.debug(`Failed loading Claude settings from`, local(settings_json))
62
+ fromClaude.cached = null
63
+ }
64
+ return fromClaude.cached
65
+ }
66
+
67
+ function fromOpencode() {
68
+ if ("cached" in fromOpencode) return fromOpencode.cached
69
+ const opencode_json = path.join(HOME, ".config/opencode/opencode.json")
70
+ try {
71
+ let conf = JSON.parse(fs.readFileSync(opencode_json, "utf8"))
72
+ LOG.debug(`Loaded OpenCode settings from`, local(opencode_json))
73
+ // https://opencode.ai/config.json
74
+ let o = conf?.provider?.anthropic?.options
75
+ if (!o) return (fromOpencode.cached = null)
76
+ let any,
77
+ config = {}
78
+ if ((any = o.anthropicApiUrl ?? o.apiUrl ?? o.baseURL))
79
+ config.anthropicApiUrl = any.replace(/\/v1$/, "") // opencode expects the versioned baseUrl, others do not
80
+ if ((any = o.anthropicApiKey ?? o.apiKey)) config.apiKey = any
81
+ if ((any = conf?.model)) config.model = any.replace("anthropic/", "")
82
+ fromOpencode.cached = Object.keys(config).length ? config : null
83
+ LOG.debug(`Loaded config from`, local(opencode_json), ":", sanitized(config))
84
+ } catch {
85
+ LOG.debug(`Failed loading OpenCode settings from`, local(opencode_json))
86
+ fromOpencode.cached = null
87
+ }
88
+ return fromOpencode.cached
89
+ }
90
+
91
+ const sanitized = ({ apiKey, ...rest }) => ({
92
+ ...rest,
93
+ apiKey: apiKey ? "***" : undefined,
94
+ })