@cap-js/agents 0.0.0 → 0.9.2

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 (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +154 -0
  3. package/_i18n/messages.properties +31 -0
  4. package/cds-plugin.js +140 -0
  5. package/index.cds +116 -0
  6. package/index.js +0 -0
  7. package/lib/agents/markdown/backends/mime-utils.js +37 -0
  8. package/lib/agents/markdown/backends/outputs-backend.js +156 -0
  9. package/lib/agents/markdown/backends/readonly-backend.js +48 -0
  10. package/lib/agents/markdown/backends/uploads-backend.js +160 -0
  11. package/lib/agents/markdown/deep-agent.js +82 -0
  12. package/lib/agents/middleware/agent-actions.js +18 -0
  13. package/lib/agents/middleware/content-filter.js +191 -0
  14. package/lib/agents/middleware/hitl-edit-note-injector.js +18 -0
  15. package/lib/agents/middleware/hitl.js +20 -0
  16. package/lib/agents/middleware/index.js +19 -0
  17. package/lib/agents/middleware/patch-tool-calls.js +51 -0
  18. package/lib/agents/middleware/quota-enforcer.js +94 -0
  19. package/lib/agents/middleware/status-update.js +153 -0
  20. package/lib/agents/middleware/tool-selection.js +22 -0
  21. package/lib/agents/quota-enforcer-at-start.js +198 -0
  22. package/lib/agents/summarize-on-timeout.js +91 -0
  23. package/lib/compile.js +55 -0
  24. package/lib/index.cjs +1 -0
  25. package/lib/index.js +422 -0
  26. package/lib/models/aicore.js +441 -0
  27. package/lib/models/anthropic.js +77 -0
  28. package/lib/models/mock.js +88 -0
  29. package/lib/preview/chat.html +897 -0
  30. package/lib/preview/preview.js +46 -0
  31. package/lib/protocol/agent-card.js +297 -0
  32. package/lib/protocol/persistence/checkpoint-saver.js +320 -0
  33. package/lib/protocol/persistence/cleanup.js +84 -0
  34. package/lib/protocol/persistence/file-store.js +209 -0
  35. package/lib/protocol/persistence/push-notification-store.js +59 -0
  36. package/lib/protocol/persistence/task-store.js +47 -0
  37. package/lib/protocol/push-notification-sender.js +57 -0
  38. package/lib/sidecar.js +162 -0
  39. package/lib/telemetry/active-users.js +106 -0
  40. package/lib/telemetry/chat-tracing.js +364 -0
  41. package/lib/telemetry/metrics.js +85 -0
  42. package/lib/telemetry/mlflow.js +290 -0
  43. package/lib/telemetry/tool-tracing.js +164 -0
  44. package/lib/telemetry/tracing.js +150 -0
  45. package/lib/utils/inner-auth.js +33 -0
  46. package/lib/utils/markdown.js +199 -0
  47. package/lib/utils/message-handling.js +155 -0
  48. package/lib/utils/utils.js +168 -0
  49. package/package.json +225 -2
  50. package/srv/graph-cache.js +82 -0
  51. package/srv/handlers/graph-executor.js +1374 -0
  52. package/srv/handlers/index.js +178 -0
  53. package/srv/handlers/mcp-tools.js +161 -0
  54. package/srv/handlers/sub-agent-tools.js +316 -0
  55. package/srv/handlers/system-prompt.js +25 -0
  56. package/srv/handlers/tools.js +366 -0
  57. package/srv/langgraph-executor-srv.js +70 -0
  58. package/srv/push-notification-srv.js +149 -0
@@ -0,0 +1,18 @@
1
+ import { createMiddleware } from "langchain"
2
+ import { HumanMessage } from "@langchain/core/messages"
3
+ import { z } from "zod"
4
+
5
+ // Injects a HumanMessage for a pending _hitlEditNote before the next model turn.
6
+ export function hitlEditNoteInjectorMiddleware() {
7
+ return createMiddleware({
8
+ name: "hitlEditNoteInjectorMiddleware",
9
+ stateSchema: z.object({ _hitlEditNote: z.string().optional() }),
10
+ beforeModel: async (state) => {
11
+ if (!state._hitlEditNote) return
12
+ return {
13
+ messages: [new HumanMessage(state._hitlEditNote)],
14
+ _hitlEditNote: undefined,
15
+ }
16
+ },
17
+ })
18
+ }
@@ -0,0 +1,20 @@
1
+ import { humanInTheLoopMiddleware as hitl } from "langchain"
2
+ import { hitlEditNoteInjectorMiddleware } from "./hitl-edit-note-injector.js"
3
+
4
+ function buildHitlInterruptMap(srv, tools = []) {
5
+ return tools.reduce((interruptOn, tool) => {
6
+ if (
7
+ srv.actions[tool.name]?.["@agent.hitl"] ??
8
+ srv.actions[tool.name]?.["@Common.IsActionCritical"]
9
+ ) {
10
+ interruptOn[tool.name] = { allowedDecisions: ["approve", "reject", "edit"] }
11
+ }
12
+ return interruptOn
13
+ }, {})
14
+ }
15
+
16
+ export async function humanInTheLoopMiddleware(srv, tools) {
17
+ const interruptOn = buildHitlInterruptMap(srv, tools)
18
+ if (!Object.keys(interruptOn).length) return []
19
+ return [hitl({ interruptOn }), hitlEditNoteInjectorMiddleware()]
20
+ }
@@ -0,0 +1,19 @@
1
+ export default async function buildMiddleware(srv, options = {}) {
2
+ const { tools, model } = options
3
+ const { quotaEnforcerMiddleware } = await import("./quota-enforcer.js")
4
+ const { contentFilterMiddleware } = await import("./content-filter.js")
5
+ const { agentActionsMiddleware } = await import("./agent-actions.js")
6
+ const { patchToolCallsMiddleware } = await import("./patch-tool-calls.js")
7
+ const { statusUpdateMiddleware } = await import("./status-update.js")
8
+ const { humanInTheLoopMiddleware } = await import("./hitl.js")
9
+ const { toolSelectionMiddleware } = await import("./tool-selection.js")
10
+ return [
11
+ ...(await quotaEnforcerMiddleware()),
12
+ await contentFilterMiddleware(model),
13
+ await agentActionsMiddleware(),
14
+ patchToolCallsMiddleware(),
15
+ await statusUpdateMiddleware(),
16
+ ...(await humanInTheLoopMiddleware(srv, tools)),
17
+ toolSelectionMiddleware(),
18
+ ].filter(Boolean)
19
+ }
@@ -0,0 +1,51 @@
1
+ import { createMiddleware } from "langchain"
2
+ import { AIMessage, ToolMessage } from "@langchain/core/messages"
3
+
4
+ /**
5
+ * Middleware that patches dangling tool calls before each model call.
6
+ *
7
+ * When a task is interrupted mid-flight (quota exceeded, timeout, or cancellation),
8
+ * the checkpoint history may end with an AIMessage containing tool_calls that were
9
+ * never executed — no matching ToolMessage was written because the tools node never
10
+ * ran. On the next turn, sending this history to the LLM causes a 400 error (e.g.
11
+ * AI Core, Anthropic) because providers enforce strict tool_call / tool_result parity.
12
+ *
13
+ */
14
+ export function patchToolCallsMiddleware() {
15
+ return createMiddleware({
16
+ name: "customPatchToolCallsMiddleware", // deepagents have their own middleware with the same name
17
+ wrapModelCall: async (request, handler) => {
18
+ const messages = request.messages
19
+ if (!messages?.length) return handler(request)
20
+
21
+ const patched = []
22
+ let needsPatch = false
23
+
24
+ for (let i = 0; i < messages.length; i++) {
25
+ const msg = messages[i]
26
+ patched.push(msg)
27
+
28
+ if (AIMessage.isInstance(msg) && msg.tool_calls?.length) {
29
+ for (const tc of msg.tool_calls) {
30
+ const hasResponse = messages
31
+ .slice(i + 1)
32
+ .some((m) => ToolMessage.isInstance(m) && m.tool_call_id === tc.id)
33
+
34
+ if (!hasResponse) {
35
+ needsPatch = true
36
+ patched.push(
37
+ new ToolMessage({
38
+ content: `Tool call ${tc.name} was cancelled before it could complete.`,
39
+ name: tc.name,
40
+ tool_call_id: tc.id,
41
+ }),
42
+ )
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ return handler(needsPatch ? { ...request, messages: patched } : request)
49
+ },
50
+ })
51
+ }
@@ -0,0 +1,94 @@
1
+ import cds from "@sap/cds"
2
+ import { audit } from "../../utils/utils.js"
3
+ import { createMiddleware } from "langchain"
4
+ import { AIMessage } from "@langchain/core/messages"
5
+ import { z } from "zod"
6
+
7
+ /**
8
+ * Quota enforcement middleware for deep agents.
9
+ * Reads limits from cds.env.agents.pool at check time (not creation time).
10
+ * Returns array to spread into middleware config.
11
+ */
12
+ export async function quotaEnforcerMiddleware() {
13
+ return [
14
+ createMiddleware({
15
+ name: "agentQuotaEnforcerMiddleware",
16
+ stateSchema: z.object({
17
+ runModelCallCount: z.number().default(0),
18
+ runTokenCount: z.number().default(0),
19
+ runToolCallCount: z.number().default(0),
20
+ }),
21
+ afterModel: {
22
+ canJumpTo: ["end"],
23
+ hook: (state) => {
24
+ const pool = cds.env.agents?.pool || {}
25
+ const newCallCount = state.runModelCallCount + 1
26
+
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)`
30
+ audit("QuotaExceeded", {
31
+ data: {
32
+ service: cds.context?.["agent.service"],
33
+ user: cds.context?.user?.id,
34
+ reason,
35
+ taskId: cds.context?.["agent.task.id"],
36
+ },
37
+ })
38
+ const err = new Error(reason)
39
+ err.quotaExceeded = true
40
+ throw err
41
+ }
42
+
43
+ // Accumulate token usage
44
+ const lastAI = [...state.messages].reverse().find(AIMessage.isInstance)
45
+ const usage = lastAI?.usage_metadata
46
+ const consumed = (usage?.input_tokens || 0) + (usage?.output_tokens || 0)
47
+ const newTokenCount = state.runTokenCount + consumed
48
+
49
+ // Check token limit
50
+ if (pool.maxLLMTokensPerTask && newTokenCount > pool.maxLLMTokensPerTask) {
51
+ const reason = `Token limit exceeded: ${newTokenCount} tokens (max ${pool.maxLLMTokensPerTask} per task)`
52
+ audit("QuotaExceeded", {
53
+ data: {
54
+ service: cds.context?.["agent.service"],
55
+ user: cds.context?.user?.id,
56
+ reason,
57
+ taskId: cds.context?.["agent.task.id"],
58
+ },
59
+ })
60
+ const err = new Error(reason)
61
+ err.quotaExceeded = true
62
+ throw err
63
+ }
64
+
65
+ // Count tool calls from latest AI message
66
+ const toolCalls = lastAI?.tool_calls?.length || 0
67
+ const newToolCallCount = state.runToolCallCount + toolCalls
68
+
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)`
72
+ audit("QuotaExceeded", {
73
+ data: {
74
+ service: cds.context?.["agent.service"],
75
+ user: cds.context?.user?.id,
76
+ reason,
77
+ taskId: cds.context?.["agent.task.id"],
78
+ },
79
+ })
80
+ const err = new Error(reason)
81
+ err.quotaExceeded = true
82
+ throw err
83
+ }
84
+
85
+ return {
86
+ runModelCallCount: newCallCount,
87
+ runTokenCount: newTokenCount,
88
+ runToolCallCount: newToolCallCount,
89
+ }
90
+ },
91
+ },
92
+ }),
93
+ ]
94
+ }
@@ -0,0 +1,153 @@
1
+ import cds from "@sap/cds"
2
+ import { createMiddleware } from "langchain"
3
+ import { ToolMessage } from "@langchain/core/messages"
4
+
5
+ /**
6
+ * Resolves a human-readable label for a tool call.
7
+ *
8
+ * - query tool → resolves entity label from CDS model: @Common.Label, @title, or i18n
9
+ * - actions → resolves label from action definition
10
+ * - fallback → tool name as-is
11
+ */
12
+ function resolveToolLabel(tc) {
13
+ const serviceName = cds.context?.["agent.service"]
14
+ const srv = serviceName && cds.services?.[serviceName]
15
+ const model = cds.context?.model ?? cds.model
16
+
17
+ // Query tool: extract entity target and resolve its label
18
+ if (tc.name === "query" || tc.name?.endsWith("_query")) {
19
+ let entityName = tc.args?.entity ? `${serviceName}.${tc.args.entity}` : undefined
20
+
21
+ // SQL format: no entity arg — parse SQL to extract FROM target
22
+ if (!entityName && tc.args?.cql) {
23
+ try {
24
+ const cqn = cds.parse.cql(tc.args.cql)
25
+ const ref0 = cqn.SELECT?.from?.ref?.[0]
26
+ const targetName = ref0?.id ?? ref0
27
+ if (targetName && serviceName && !targetName.startsWith(serviceName + ".")) {
28
+ entityName = `${serviceName}.${targetName}`
29
+ } else {
30
+ entityName = targetName
31
+ }
32
+ } catch {
33
+ /* fallback to tc.name below */
34
+ }
35
+ }
36
+ if (entityName) {
37
+ const entityDef = model.definitions?.[entityName]
38
+ if (entityDef) {
39
+ const label = cds.i18n?.labels?.at(entityDef)
40
+ if (label) return label
41
+ }
42
+ }
43
+ // Fallback: strip service prefix if present
44
+ if (entityName && srv?.name && entityName.startsWith(srv.name + ".")) {
45
+ return entityName.slice(srv.name.length + 1)
46
+ }
47
+ return entityName || tc.name
48
+ }
49
+
50
+ // Action/function tools: resolve from service definition
51
+ if (srv) {
52
+ // Try as action on service
53
+ const actionDef = model.definitions[`${srv.name}.${tc.name}`]
54
+ if (actionDef) {
55
+ const label = cds.i18n?.labels?.at(actionDef)
56
+ if (label) return label
57
+ }
58
+ }
59
+
60
+ return tc.name
61
+ }
62
+
63
+ /**
64
+ * Publishes a non-final "working" status-update to the eventBus.
65
+ */
66
+ function publishStatus(text) {
67
+ const eventBus = cds.context?.["agent.eventBus"]
68
+ if (!eventBus || !text) return
69
+
70
+ eventBus.publish({
71
+ kind: "status-update",
72
+ taskId: cds.context["agent.task.id"],
73
+ contextId: cds.context["agent.context.id"],
74
+ status: {
75
+ state: "working",
76
+ message: {
77
+ kind: "message",
78
+ messageId: cds.utils.uuid(),
79
+ role: "agent",
80
+ parts: [{ kind: "text", text }],
81
+ },
82
+ timestamp: new Date().toISOString(),
83
+ },
84
+ final: false,
85
+ })
86
+ }
87
+
88
+ /**
89
+ * beforeModel hook: emit "Processing tool response" when tools just finished.
90
+ */
91
+ export function beforeModelHook(state) {
92
+ if (!cds.context?.["agent.eventBus"]) return {}
93
+
94
+ const msgs = state.messages
95
+ if (!msgs?.length) return {}
96
+ const lastMsg = msgs[msgs.length - 1]
97
+ if (!ToolMessage.isInstance(lastMsg)) return {}
98
+
99
+ // Plural if second-to-last is also a ToolMessage
100
+ const plural = msgs.length >= 2 && ToolMessage.isInstance(msgs[msgs.length - 2])
101
+ const key = plural ? "agent_status_processing_responses" : "agent_status_processing_response"
102
+ const text = cds.i18n.messages.at(key)
103
+ publishStatus(text)
104
+
105
+ return {}
106
+ }
107
+
108
+ /**
109
+ * afterModel hook: emit tool-call status updates (querying/calling).
110
+ */
111
+ export function afterModelHook(state) {
112
+ if (!cds.context?.["agent.eventBus"]) return {}
113
+
114
+ const msgs = state.messages
115
+ if (!msgs?.length) return {}
116
+
117
+ const lastAI = msgs[msgs.length - 1]
118
+ const toolCalls = lastAI?.tool_calls
119
+ if (!toolCalls?.length) return {}
120
+
121
+ // Separate query calls from action calls
122
+ const queryCalls = toolCalls.filter((tc) => tc.name === "query" || tc.name?.endsWith("_query"))
123
+ const otherCalls = toolCalls.filter((tc) => tc.name !== "query" && !tc.name?.endsWith("_query"))
124
+
125
+ // Emit "Querying {entities}" for query tools
126
+ if (queryCalls.length) {
127
+ const labels = queryCalls.map((tc) => resolveToolLabel(tc))
128
+ const text = cds.i18n.messages.at("agent_status_querying", [labels.join(", ")])
129
+ publishStatus(text)
130
+ }
131
+
132
+ // Emit "Calling {action labels}" for other tools
133
+ if (otherCalls.length) {
134
+ const labels = otherCalls.map((tc) => resolveToolLabel(tc))
135
+ const text = cds.i18n.messages.at("agent_status_calling_tools", [labels.join(", ")])
136
+ publishStatus(text)
137
+ }
138
+
139
+ return {}
140
+ }
141
+
142
+ /**
143
+ * Middleware factory that emits non-final status-update events during agent execution:
144
+ * - beforeModel: "Processing tool response" after tools finish
145
+ * - afterModel: "Querying <entity>" / "Calling <action>" before tools are invoked
146
+ */
147
+ export async function statusUpdateMiddleware() {
148
+ return createMiddleware({
149
+ name: "statusUpdateMiddleware",
150
+ beforeModel: { hook: beforeModelHook },
151
+ afterModel: { hook: afterModelHook },
152
+ })
153
+ }
@@ -0,0 +1,22 @@
1
+ import { createMiddleware } from "langchain"
2
+
3
+ /**
4
+ * Middleware that filters the tool list passed to the model on every call.
5
+ * The graph is cached once with all tools; this middleware trims it to the
6
+ * subset the current user is authorized to see (checkAuthorization runs per-request).
7
+ *
8
+ * Tools with an isAllowed() method (GenericReadTool, DescribeTool, etc.) are dropped
9
+ * when the method returns false. Their description and schema getters return
10
+ * auth-filtered content dynamically, so the model always sees only what the user
11
+ * can access — no new instances, no renamed tools.
12
+ */
13
+ export function toolSelectionMiddleware() {
14
+ return createMiddleware({
15
+ name: "ToolSelectionMiddleware",
16
+ wrapModelCall: (request, handler) => {
17
+ if (!request.tools?.length) return handler(request)
18
+ const tools = request.tools.filter((t) => (t.isAllowed ? t.isAllowed() : true))
19
+ return handler({ ...request, tools })
20
+ },
21
+ })
22
+ }
@@ -0,0 +1,198 @@
1
+ import cds from "@sap/cds"
2
+
3
+ const LOG = cds.log("agents")
4
+
5
+ const tasks = () => cds.model.definitions["cap.agent.Tasks"]
6
+
7
+ function secondsUntilNextHour() {
8
+ const now = new Date()
9
+ const next = new Date(now)
10
+ next.setUTCMinutes(0, 0, 0)
11
+ next.setUTCHours(next.getUTCHours() + 1)
12
+ return Math.ceil((next - now) / 1000)
13
+ }
14
+
15
+ function secondsUntilMidnightUTC() {
16
+ const now = new Date()
17
+ const midnight = new Date(now)
18
+ midnight.setUTCHours(24, 0, 0, 0)
19
+ return Math.ceil((midnight - now) / 1000)
20
+ }
21
+
22
+ /**
23
+ * Quota enforcement before graph execution.
24
+ * Returns null if within limits, or { message, retryAfter } if a limit is breached.
25
+ */
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
+ )
32
+ return null
33
+ }
34
+
35
+ // REVISIT: If applications ask for it, add agent quota annotations which allow to enforce agent service specific quotas.
36
+ // Keep extensibility in mind that customers then would not be able to override own limits.
37
+ const lastHour = new Date(Date.now() - 60 * 60 * 1000)
38
+ const today = new Date()
39
+ today.setUTCHours(0, 0, 0, 0)
40
+
41
+ const userId = cds.context?.user?.id
42
+
43
+ const [
44
+ {
45
+ concurrentTasks,
46
+ lastHourTasks,
47
+ concurrentTasksThisUser,
48
+ lastHourTasksThisUser,
49
+ lastHourToolCalls,
50
+ },
51
+ { llmTokensThisDay },
52
+ ] = await Promise.all([
53
+ SELECT.one
54
+ .from(tasks())
55
+ .columns(
56
+ concurrentTasksCol,
57
+ "count(*) as lastHourTasks",
58
+ concurrentTasksThisUserColFactory(userId),
59
+ lastHourTasksThisUserColFactory(userId),
60
+ "coalesce(sum(usageToolCalls), 0) as lastHourToolCalls",
61
+ )
62
+ .where({ createdAt: { ">=": lastHour.toISOString() } }),
63
+ SELECT.one
64
+ .from(tasks())
65
+ .columns("coalesce(sum(usageLlmTokens),0) as llmTokensThisDay")
66
+ .where({ createdAt: { ">=": today.toISOString() } }),
67
+ ])
68
+ if (pool.maxConcurrentTasks != null && concurrentTasks >= pool.maxConcurrentTasks) {
69
+ return {
70
+ message: cds.i18n.messages.at("QUOTA_CONCURRENT_TASKS", [pool.maxConcurrentTasks]),
71
+ retryAfter: 30,
72
+ }
73
+ }
74
+ if (
75
+ pool.maxConcurrentTasksPerUser != null &&
76
+ concurrentTasksThisUser >= pool.maxConcurrentTasksPerUser
77
+ ) {
78
+ return {
79
+ message: cds.i18n.messages.at("QUOTA_CONCURRENT_TASKS_PER_USER", [
80
+ pool.maxConcurrentTasksPerUser,
81
+ ]),
82
+ retryAfter: 30,
83
+ }
84
+ }
85
+ if (pool.maxTasksPerHour != null && lastHourTasks >= pool.maxTasksPerHour) {
86
+ return {
87
+ message: cds.i18n.messages.at("QUOTA_TASKS_PER_HOUR", [pool.maxTasksPerHour]),
88
+ retryAfter: secondsUntilNextHour(),
89
+ }
90
+ }
91
+ if (pool.maxTasksPerHourPerUser != null && lastHourTasksThisUser >= pool.maxTasksPerHourPerUser) {
92
+ return {
93
+ message: cds.i18n.messages.at("QUOTA_TASKS_PER_HOUR_PER_USER", [pool.maxTasksPerHourPerUser]),
94
+ retryAfter: secondsUntilNextHour(),
95
+ }
96
+ }
97
+ if (pool.maxToolCallsPerHour != null && lastHourToolCalls >= pool.maxToolCallsPerHour) {
98
+ return {
99
+ message: cds.i18n.messages.at("QUOTA_TOOL_CALLS_PER_HOUR", [pool.maxToolCallsPerHour]),
100
+ retryAfter: secondsUntilNextHour(),
101
+ }
102
+ }
103
+ if (pool.maxLLMTokensPerDay != null && llmTokensThisDay >= pool.maxLLMTokensPerDay) {
104
+ return {
105
+ message: cds.i18n.messages.at("QUOTA_LLM_TOKENS_PER_DAY", [pool.maxLLMTokensPerDay]),
106
+ retryAfter: secondsUntilMidnightUTC(),
107
+ }
108
+ }
109
+ return null
110
+ }
111
+
112
+ const concurrentTasksCol = {
113
+ func: "coalesce",
114
+ args: [
115
+ {
116
+ func: "sum",
117
+ args: [
118
+ {
119
+ xpr: [
120
+ "case",
121
+ "when",
122
+ { ref: ["state"] },
123
+ "in",
124
+ { list: [{ val: "submitted" }, { val: "working" }, { val: "input-required" }] },
125
+ "then",
126
+ { val: 1, param: false },
127
+ "else",
128
+ { val: 0, param: false },
129
+ "end",
130
+ ],
131
+ cast: { type: "cds.Integer" },
132
+ },
133
+ ],
134
+ },
135
+ { val: 0 },
136
+ ],
137
+ as: "concurrentTasks",
138
+ }
139
+
140
+ const concurrentTasksThisUserColFactory = (userId) => ({
141
+ func: "coalesce",
142
+ args: [
143
+ {
144
+ func: "sum",
145
+ args: [
146
+ {
147
+ xpr: [
148
+ "case",
149
+ "when",
150
+ { ref: ["createdBy"] },
151
+ "=",
152
+ { val: userId },
153
+ "and",
154
+ { ref: ["state"] },
155
+ "in",
156
+ { list: [{ val: "submitted" }, { val: "working" }, { val: "input-required" }] },
157
+ "then",
158
+ { val: 1, param: false },
159
+ "else",
160
+ { val: 0, param: false },
161
+ "end",
162
+ ],
163
+ cast: { type: "cds.Integer" },
164
+ },
165
+ ],
166
+ },
167
+ { val: 0 },
168
+ ],
169
+ as: "concurrentTasksThisUser",
170
+ })
171
+
172
+ const lastHourTasksThisUserColFactory = (userId) => ({
173
+ func: "coalesce",
174
+ args: [
175
+ {
176
+ func: "sum",
177
+ args: [
178
+ {
179
+ xpr: [
180
+ "case",
181
+ "when",
182
+ { ref: ["createdBy"] },
183
+ "=",
184
+ { val: userId },
185
+ "then",
186
+ { val: 1, param: false },
187
+ "else",
188
+ { val: 0, param: false },
189
+ "end",
190
+ ],
191
+ cast: { type: "cds.Integer" },
192
+ },
193
+ ],
194
+ },
195
+ { val: 0 },
196
+ ],
197
+ as: "lastHourTasksThisUser",
198
+ })
@@ -0,0 +1,91 @@
1
+ import cds from "@sap/cds"
2
+ import { HumanMessage } from "@langchain/core/messages"
3
+ import { short } from "../utils/utils.js"
4
+
5
+ const LOG = cds.log("agents")
6
+
7
+ const DEFAULT_TIMEOUT = 10_000
8
+
9
+ /**
10
+ * Summarize partial work from a graph execution that was interrupted
11
+ * (timeout, quota exceeded, or other forced stop).
12
+ *
13
+ * Reads partial conversation from the checkpoint and asks the LLM to
14
+ * produce a concise progress summary. Falls back to a generic message
15
+ * on any failure (no checkpointer, empty state, LLM unreachable).
16
+ *
17
+ * @param {object} options
18
+ * @param {string} options.contextId
19
+ * @param {string} options.serviceName
20
+ * @param {string} options.reason - Why execution was interrupted (e.g. "timed out", "quota exceeded")
21
+ * @param {object} [options.checkpointer] - LangGraph checkpointer instance
22
+ * @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
+ * @returns {Promise<string>} Summary message or fallback
25
+ */
26
+ export async function summarizePartialWork({
27
+ contextId,
28
+ serviceName,
29
+ reason,
30
+ checkpointer,
31
+ getModel,
32
+ timeout = DEFAULT_TIMEOUT,
33
+ }) {
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
+
36
+ try {
37
+ if (!getModel) return fallback
38
+ if (!checkpointer?.getTuple) return fallback
39
+
40
+ const cp = await checkpointer.getTuple({
41
+ configurable: { thread_id: `${serviceName}:${contextId}` },
42
+ })
43
+ const messages = cp?.checkpoint?.channel_values?.messages
44
+ if (!messages?.length) return fallback
45
+
46
+ // Extract last few messages as context for summarization (cap at 10)
47
+ const recentMessages = messages.slice(-10)
48
+ const conversationSnippet = recentMessages
49
+ .map((m) => {
50
+ const role = m._getType?.() || m.type || "unknown"
51
+ const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content)
52
+ return `[${role}]: ${content?.slice(0, 500)}`
53
+ })
54
+ .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
+
60
+ let summaryTimer
61
+ try {
62
+ const response = await Promise.race([
63
+ (async () => {
64
+ const model = await getModel()
65
+ return model.invoke([new HumanMessage(summaryPrompt)])
66
+ })(),
67
+ new Promise((_, reject) => {
68
+ summaryTimer = setTimeout(() => reject(new Error("Summary LLM call timed out")), timeout)
69
+ }),
70
+ ])
71
+
72
+ const summary =
73
+ typeof response.content === "string" ? response.content : JSON.stringify(response.content)
74
+
75
+ LOG.info("partial work summary generated", {
76
+ conversation: short(contextId),
77
+ service: serviceName,
78
+ reason,
79
+ })
80
+ return `Task stopped (${reason}). Progress summary: ${summary}`
81
+ } finally {
82
+ clearTimeout(summaryTimer)
83
+ }
84
+ } catch (err) {
85
+ LOG.debug("partial work summary failed, using fallback", {
86
+ conversation: short(contextId),
87
+ error: err.message,
88
+ })
89
+ return fallback
90
+ }
91
+ }