@cap-js/agents 0.0.0 → 0.9.1
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/LICENSE +201 -0
- package/README.md +155 -0
- package/_i18n/messages.properties +31 -0
- package/cds-plugin.js +140 -0
- package/index.cds +105 -0
- package/index.js +0 -0
- package/lib/agents/markdown/backends/mime-utils.js +37 -0
- package/lib/agents/markdown/backends/outputs-backend.js +152 -0
- package/lib/agents/markdown/backends/uploads-backend.js +143 -0
- package/lib/agents/markdown/deep-agent.js +93 -0
- package/lib/agents/middleware/agent-actions.js +18 -0
- package/lib/agents/middleware/content-filter.js +191 -0
- package/lib/agents/middleware/hitl-edit-note-injector.js +18 -0
- package/lib/agents/middleware/hitl.js +20 -0
- package/lib/agents/middleware/index.js +19 -0
- package/lib/agents/middleware/patch-tool-calls.js +51 -0
- package/lib/agents/middleware/quota-enforcer.js +94 -0
- package/lib/agents/middleware/status-update.js +153 -0
- package/lib/agents/middleware/tool-selection.js +22 -0
- package/lib/agents/quota-enforcer-at-start.js +198 -0
- package/lib/agents/summarize-on-timeout.js +91 -0
- package/lib/compile.js +55 -0
- package/lib/index.cjs +1 -0
- package/lib/index.js +422 -0
- package/lib/models/aicore.js +441 -0
- package/lib/models/anthropic.js +77 -0
- package/lib/models/mock.js +88 -0
- package/lib/preview/chat.html +875 -0
- package/lib/preview/preview.js +46 -0
- package/lib/protocol/agent-card.js +297 -0
- package/lib/protocol/persistence/checkpoint-saver.js +317 -0
- package/lib/protocol/persistence/file-store.js +209 -0
- package/lib/protocol/persistence/push-notification-store.js +59 -0
- package/lib/protocol/persistence/task-store.js +47 -0
- package/lib/protocol/push-notification-sender.js +57 -0
- package/lib/sidecar.js +162 -0
- package/lib/telemetry/active-users.js +106 -0
- package/lib/telemetry/chat-tracing.js +342 -0
- package/lib/telemetry/metrics.js +85 -0
- package/lib/telemetry/mlflow.js +290 -0
- package/lib/telemetry/tool-tracing.js +164 -0
- package/lib/telemetry/tracing.js +150 -0
- package/lib/utils/inner-auth.js +33 -0
- package/lib/utils/markdown.js +199 -0
- package/lib/utils/message-handling.js +155 -0
- package/lib/utils/utils.js +168 -0
- package/package.json +225 -2
- package/srv/graph-cache.js +82 -0
- package/srv/handlers/graph-executor.js +1369 -0
- package/srv/handlers/index.js +173 -0
- package/srv/handlers/mcp-tools.js +159 -0
- package/srv/handlers/sub-agent-tools.js +314 -0
- package/srv/handlers/system-prompt.js +25 -0
- package/srv/handlers/tools.js +366 -0
- package/srv/langgraph-executor-srv.js +70 -0
- package/srv/push-notification-srv.js +149 -0
|
@@ -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
|
+
}
|
package/lib/compile.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import cds from "@sap/cds"
|
|
2
|
+
import { getDescription, getFilteredEntities, getFilteredActions } from "./utils/utils.js"
|
|
3
|
+
import { buildAgentCard } from "./protocol/agent-card.js"
|
|
4
|
+
import { slugified } from "./utils/markdown.js"
|
|
5
|
+
|
|
6
|
+
const A2A_BASE_PATH = "/a2a"
|
|
7
|
+
|
|
8
|
+
function cds_compile_to_a2a(csn, options = {}) {
|
|
9
|
+
const model = cds.linked(csn)
|
|
10
|
+
const services = model.services
|
|
11
|
+
|
|
12
|
+
if (services.length === 0) {
|
|
13
|
+
throw new Error("No service definitions found in given model(s).")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!options.service && services.length > 1) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Found multiple service definitions in given model(s).` +
|
|
19
|
+
`\nPlease choose by adding one of...${services.map((s) => `\n -s ${s.name}`).join("")}`,
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let def
|
|
24
|
+
if (!options.service) {
|
|
25
|
+
def = services[0]
|
|
26
|
+
} else {
|
|
27
|
+
def = services.find((s) => s.name === options.service)
|
|
28
|
+
if (!def) {
|
|
29
|
+
throw new Error(`No service definition matching ${options.service} found in given model(s).`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Build service path — respect @path annotation, otherwise use slugified name
|
|
34
|
+
const customPath = def["@path"]
|
|
35
|
+
const servicePath = customPath ? customPath.replace(/^\//, "") : slugified(def.name)
|
|
36
|
+
|
|
37
|
+
// If service is behind a proxy, @Core.Links with rel='via' provides the proxy URL
|
|
38
|
+
const viaLink = def["@Core.Links"]?.find((l) => l.rel === "via")
|
|
39
|
+
const url = viaLink?.href ?? `https://HOST${A2A_BASE_PATH}/${servicePath}`
|
|
40
|
+
|
|
41
|
+
const agentCard = buildAgentCard({
|
|
42
|
+
name: def.name,
|
|
43
|
+
description: getDescription(def, "en") || `Agent for ${def.name}`,
|
|
44
|
+
entities: getFilteredEntities(def),
|
|
45
|
+
actions: getFilteredActions(def),
|
|
46
|
+
url,
|
|
47
|
+
streaming: true,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
if (/^(?:obj|object)$/i.test(options.as)) return agentCard
|
|
51
|
+
|
|
52
|
+
return JSON.stringify(agentCard, null, 2)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default cds_compile_to_a2a
|
package/lib/index.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require("./index.js").default
|