@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.
- package/LICENSE +201 -0
- package/README.md +154 -0
- package/_i18n/messages.properties +31 -0
- package/cds-plugin.js +140 -0
- package/index.cds +116 -0
- package/index.js +0 -0
- package/lib/agents/markdown/backends/mime-utils.js +37 -0
- package/lib/agents/markdown/backends/outputs-backend.js +156 -0
- package/lib/agents/markdown/backends/readonly-backend.js +48 -0
- package/lib/agents/markdown/backends/uploads-backend.js +160 -0
- package/lib/agents/markdown/deep-agent.js +82 -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 +897 -0
- package/lib/preview/preview.js +46 -0
- package/lib/protocol/agent-card.js +297 -0
- package/lib/protocol/persistence/checkpoint-saver.js +320 -0
- package/lib/protocol/persistence/cleanup.js +84 -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 +364 -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 +1374 -0
- package/srv/handlers/index.js +178 -0
- package/srv/handlers/mcp-tools.js +161 -0
- package/srv/handlers/sub-agent-tools.js +316 -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,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
|
+
}
|