melchizedek-agents 0.16.2 → 0.18.0
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/.env.example +40 -3
- package/AGENT_SETUP.md +4 -2
- package/CHANGELOG.md +246 -0
- package/DOCUMENTATION.md +311 -15
- package/QUICKSTART.md +2 -2
- package/README.md +125 -149
- package/config/agents/examples/harness.yaml +135 -0
- package/config/agents/examples/model_zoo.yaml +23 -9
- package/config/agents/examples/pipeline.yaml +120 -0
- package/config/agents/examples/specs/open-meteo-forecast.json +77 -0
- package/config/agents/examples/specs/open-meteo-geocoding.json +53 -0
- package/config/agents/examples/weather.yaml +59 -0
- package/config/agents/syndicateSchema.yaml +125 -1
- package/db/hardening.sql +4 -3
- package/db/migrations/0011_erase_expired.sql +198 -0
- package/db/migrations/0012_audit_log.sql +98 -0
- package/dist/lib/a2a/app.d.ts +27 -0
- package/dist/lib/a2a/app.js +101 -5
- package/dist/lib/a2a/executor.d.ts +34 -4
- package/dist/lib/a2a/executor.js +109 -9
- package/dist/lib/compile.d.ts +41 -3
- package/dist/lib/compile.js +162 -17
- package/dist/lib/config.d.ts +24 -0
- package/dist/lib/config.js +24 -0
- package/dist/lib/doctor.js +17 -4
- package/dist/lib/index.d.ts +2 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/loadEnv.d.ts +5 -0
- package/dist/lib/loadEnv.js +7 -0
- package/dist/lib/loadSyndicate.d.ts +50 -0
- package/dist/lib/loadSyndicate.js +19 -3
- package/dist/lib/memory/erase.d.ts +5 -0
- package/dist/lib/memory/erase.js +5 -0
- package/dist/lib/memory/providers.d.ts +27 -0
- package/dist/lib/memory/providers.js +28 -0
- package/dist/lib/models/capabilities.js +10 -0
- package/dist/lib/models/endpoints.d.ts +1 -1
- package/dist/lib/models/endpoints.js +4 -1
- package/dist/lib/models/fallback.d.ts +41 -0
- package/dist/lib/models/fallback.js +105 -0
- package/dist/lib/models/gateway.d.ts +2 -2
- package/dist/lib/models/gateway.js +4 -4
- package/dist/lib/models/kimiLlm.d.ts +123 -0
- package/dist/lib/models/kimiLlm.js +171 -0
- package/dist/lib/models/providerMap.d.ts +2 -2
- package/dist/lib/models/providerMap.js +4 -1
- package/dist/lib/models/registry.d.ts +1 -1
- package/dist/lib/models/registry.js +11 -3
- package/dist/lib/net/redirects.d.ts +14 -0
- package/dist/lib/net/redirects.js +94 -0
- package/dist/lib/observability/audit.d.ts +21 -0
- package/dist/lib/observability/audit.js +39 -0
- package/dist/lib/observability/metrics.d.ts +6 -0
- package/dist/lib/observability/tracer.d.ts +14 -1
- package/dist/lib/observability/tracer.js +16 -2
- package/dist/lib/persistence/supabaseProvider.d.ts +2 -6
- package/dist/lib/persistence/supabaseProvider.js +2 -31
- package/dist/lib/runtime/questions.d.ts +55 -0
- package/dist/lib/runtime/questions.js +107 -0
- package/dist/lib/runtime/syndicateTurn.d.ts +45 -5
- package/dist/lib/runtime/syndicateTurn.js +165 -17
- package/dist/lib/storage/postgres/index.d.ts +9 -0
- package/dist/lib/storage/postgres/index.js +13 -0
- package/dist/lib/storage/rlsStatus.d.ts +45 -0
- package/dist/lib/storage/rlsStatus.js +69 -0
- package/dist/lib/syndicateSchema.d.ts +177 -0
- package/dist/lib/syndicateSchema.js +343 -3
- package/dist/lib/toolRegistry.js +9 -0
- package/dist/lib/tools/mcpToolFactory.d.ts +26 -0
- package/dist/lib/tools/mcpToolFactory.js +61 -4
- package/dist/lib/tools/openapiTools.d.ts +52 -0
- package/dist/lib/tools/openapiTools.js +225 -0
- package/dist/lib/tools/skillToolset.d.ts +135 -0
- package/dist/lib/tools/skillToolset.js +247 -0
- package/dist/lib/tools/urlContextTool.d.ts +29 -0
- package/dist/lib/tools/urlContextTool.js +39 -0
- package/dist/lib/workflow.d.ts +72 -0
- package/dist/lib/workflow.js +180 -0
- package/dist/lib/workflowConfig.d.ts +95 -0
- package/dist/lib/workflowConfig.js +93 -0
- package/dist/scripts/a2a_server.js +51 -8
- package/dist/scripts/db.d.ts +9 -0
- package/dist/scripts/db.js +30 -15
- package/dist/scripts/syndicate_chat.js +39 -5
- package/package.json +6 -3
- package/skills/briefs/_shared.md +1 -1
- package/skills/briefs/melchizedek-author.md +8 -4
- package/skills/briefs/melchizedek-memory.md +1 -1
- package/skills/briefs/melchizedek-models.md +1 -1
- package/skills/briefs/melchizedek-scribe.md +4 -4
- package/skills/briefs/melchizedek-serve.md +1 -1
- package/skills/briefs/melchizedek.md +4 -1
- package/skills/melchizedek/SKILL.md +4 -1
- package/skills/melchizedek-author/SKILL.md +7 -3
- package/skills/melchizedek-memory/SKILL.md +1 -1
- package/skills/melchizedek-models/SKILL.md +1 -1
- package/skills/melchizedek-scribe/SKILL.md +3 -3
- package/skills/melchizedek-serve/SKILL.md +1 -1
- /package/skills/melchizedek-author/{templates → assets}/minimal.yaml +0 -0
- /package/skills/melchizedek-scribe/{templates → assets}/brief.md +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/runtime/questions.ts — an agent asks the person a question mid-turn,
|
|
3
|
+
* and the next message is the answer.
|
|
4
|
+
*
|
|
5
|
+
* WHY: A syndicate that is put in front of users needs to ask: "which of
|
|
6
|
+
* these three accounts?", "what is the order number?". Before this, an
|
|
7
|
+
* agent could only end its turn with a question in its text and hope the
|
|
8
|
+
* next message answered it, with nothing in the session saying a question
|
|
9
|
+
* was open. `ask_user` is ADK's long-running tool mechanism under one YAML
|
|
10
|
+
* name: the call is recorded, the run ends without a response to it, the
|
|
11
|
+
* turn ends `input-required` (`result.input`), and the next message on the
|
|
12
|
+
* conversation becomes the call's response, so the agent resumes its own
|
|
13
|
+
* tool loop where it asked, with the question and the answer side by side
|
|
14
|
+
* in its history.
|
|
15
|
+
*
|
|
16
|
+
* The same `input-required` path carries a workflow's `ask_user` node
|
|
17
|
+
* (lib/workflow.ts) and an approval (ADR 0028); a client handles all three
|
|
18
|
+
* by showing `result.input` (or the approval) and sending the next message.
|
|
19
|
+
*
|
|
20
|
+
* WHERE: only on an agent the turn runs directly (the orchestrator, or a
|
|
21
|
+
* plan-dispatch route), as with approval gates: inside a delegated
|
|
22
|
+
* subagent ADK swallows the pause. Not inside a workflow node yet. The
|
|
23
|
+
* schema refuses both.
|
|
24
|
+
*/
|
|
25
|
+
import { LongRunningFunctionTool } from '@google/adk';
|
|
26
|
+
import type { Event } from '@google/adk';
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
import type { PendingInput } from '../workflowConfig.ts';
|
|
29
|
+
/** The registry name, and the function-call name an open question carries. */
|
|
30
|
+
export declare const ASK_USER = "ask_user";
|
|
31
|
+
export declare const MAX_OPTIONS = 10;
|
|
32
|
+
/**
|
|
33
|
+
* The tool. Its execute returns nothing: a long-running call with no result
|
|
34
|
+
* ends the run, and ADK waits for a function response with the call's id.
|
|
35
|
+
*/
|
|
36
|
+
export declare const askUserTool: LongRunningFunctionTool<z.ZodObject<{
|
|
37
|
+
question: z.ZodString;
|
|
38
|
+
options: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
39
|
+
}, z.core.$strip>>;
|
|
40
|
+
/** The question an `ask_user` call carries, as the turn result reports it. */
|
|
41
|
+
export declare function questionFrom(author: string | undefined, call: {
|
|
42
|
+
name?: string;
|
|
43
|
+
id?: string;
|
|
44
|
+
args?: Record<string, unknown>;
|
|
45
|
+
}): PendingInput | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* The question still waiting for an answer in a session, or undefined. A
|
|
48
|
+
* question the person moved past (a text message after it was answered)
|
|
49
|
+
* is not pending; an answered one carries a function response with its id.
|
|
50
|
+
*/
|
|
51
|
+
export declare function pendingQuestion(events: readonly Event[]): PendingInput | undefined;
|
|
52
|
+
/** The message part that answers question `id` with the person's text. */
|
|
53
|
+
export declare function questionAnswerPart(id: string, answer: string): Record<string, unknown>;
|
|
54
|
+
/** Index of the user message that started the turn holding call `id` (replayed raw on a dispatch resume). */
|
|
55
|
+
export declare function turnStartOfCall(events: readonly Event[], id: string): number;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/runtime/questions.ts — an agent asks the person a question mid-turn,
|
|
3
|
+
* and the next message is the answer.
|
|
4
|
+
*
|
|
5
|
+
* WHY: A syndicate that is put in front of users needs to ask: "which of
|
|
6
|
+
* these three accounts?", "what is the order number?". Before this, an
|
|
7
|
+
* agent could only end its turn with a question in its text and hope the
|
|
8
|
+
* next message answered it, with nothing in the session saying a question
|
|
9
|
+
* was open. `ask_user` is ADK's long-running tool mechanism under one YAML
|
|
10
|
+
* name: the call is recorded, the run ends without a response to it, the
|
|
11
|
+
* turn ends `input-required` (`result.input`), and the next message on the
|
|
12
|
+
* conversation becomes the call's response, so the agent resumes its own
|
|
13
|
+
* tool loop where it asked, with the question and the answer side by side
|
|
14
|
+
* in its history.
|
|
15
|
+
*
|
|
16
|
+
* The same `input-required` path carries a workflow's `ask_user` node
|
|
17
|
+
* (lib/workflow.ts) and an approval (ADR 0028); a client handles all three
|
|
18
|
+
* by showing `result.input` (or the approval) and sending the next message.
|
|
19
|
+
*
|
|
20
|
+
* WHERE: only on an agent the turn runs directly (the orchestrator, or a
|
|
21
|
+
* plan-dispatch route), as with approval gates: inside a delegated
|
|
22
|
+
* subagent ADK swallows the pause. Not inside a workflow node yet. The
|
|
23
|
+
* schema refuses both.
|
|
24
|
+
*/
|
|
25
|
+
import { LongRunningFunctionTool } from '@google/adk';
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
/** The registry name, and the function-call name an open question carries. */
|
|
28
|
+
export const ASK_USER = 'ask_user';
|
|
29
|
+
export const MAX_OPTIONS = 10;
|
|
30
|
+
/**
|
|
31
|
+
* The tool. Its execute returns nothing: a long-running call with no result
|
|
32
|
+
* ends the run, and ADK waits for a function response with the call's id.
|
|
33
|
+
*/
|
|
34
|
+
export const askUserTool = new LongRunningFunctionTool({
|
|
35
|
+
name: ASK_USER,
|
|
36
|
+
description: 'Ask the person one question and wait for their answer before you continue: a missing detail you need, or a choice only they can make. ' +
|
|
37
|
+
'Pass `options` when the answer is one of a few choices. The turn ends here; the person\'s next message is the answer, returned to you as this call\'s result. ' +
|
|
38
|
+
'Ask only what you cannot find out or reasonably assume, one question per call.',
|
|
39
|
+
parameters: z.object({
|
|
40
|
+
question: z.string().min(1).max(500).describe('The question, as the person will read it.'),
|
|
41
|
+
options: z
|
|
42
|
+
.array(z.string().min(1).max(120))
|
|
43
|
+
.max(MAX_OPTIONS)
|
|
44
|
+
.optional()
|
|
45
|
+
.describe('The choices, when the answer is one of a few. Omit for a free answer.'),
|
|
46
|
+
}),
|
|
47
|
+
execute: async (_args, context) => {
|
|
48
|
+
if (context)
|
|
49
|
+
context.actions.skipSummarization = true;
|
|
50
|
+
return null;
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const partsOf = (e) => (e.content?.parts ?? []);
|
|
54
|
+
const hasUserText = (e) => e.author === 'user' && partsOf(e).some((p) => typeof p.text === 'string' && p.text.trim() && !p.thought);
|
|
55
|
+
/** The question an `ask_user` call carries, as the turn result reports it. */
|
|
56
|
+
export function questionFrom(author, call) {
|
|
57
|
+
if (call.name !== ASK_USER || !call.id)
|
|
58
|
+
return undefined;
|
|
59
|
+
const args = call.args ?? {};
|
|
60
|
+
const options = Array.isArray(args.options) ? args.options.filter((o) => typeof o === 'string') : [];
|
|
61
|
+
return {
|
|
62
|
+
id: call.id,
|
|
63
|
+
node: author ?? '',
|
|
64
|
+
message: typeof args.question === 'string' ? args.question : '',
|
|
65
|
+
...(options.length ? { payload: { options } } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The question still waiting for an answer in a session, or undefined. A
|
|
70
|
+
* question the person moved past (a text message after it was answered)
|
|
71
|
+
* is not pending; an answered one carries a function response with its id.
|
|
72
|
+
*/
|
|
73
|
+
export function pendingQuestion(events) {
|
|
74
|
+
const answered = new Set();
|
|
75
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
76
|
+
const e = events[i];
|
|
77
|
+
if (hasUserText(e))
|
|
78
|
+
return undefined;
|
|
79
|
+
for (const p of partsOf(e)) {
|
|
80
|
+
if (p.functionResponse?.name === ASK_USER && p.functionResponse.id)
|
|
81
|
+
answered.add(p.functionResponse.id);
|
|
82
|
+
}
|
|
83
|
+
for (const p of partsOf(e)) {
|
|
84
|
+
const call = p.functionCall;
|
|
85
|
+
if (call?.name !== ASK_USER || !call.id || answered.has(call.id))
|
|
86
|
+
continue;
|
|
87
|
+
return questionFrom(e.author, call);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
/** The message part that answers question `id` with the person's text. */
|
|
93
|
+
export function questionAnswerPart(id, answer) {
|
|
94
|
+
return { functionResponse: { id, name: ASK_USER, response: { result: answer } } };
|
|
95
|
+
}
|
|
96
|
+
/** Index of the user message that started the turn holding call `id` (replayed raw on a dispatch resume). */
|
|
97
|
+
export function turnStartOfCall(events, id) {
|
|
98
|
+
let callAt = -1;
|
|
99
|
+
for (let i = events.length - 1; i >= 0 && callAt === -1; i--) {
|
|
100
|
+
if (partsOf(events[i]).some((p) => p.functionCall?.id === id))
|
|
101
|
+
callAt = i;
|
|
102
|
+
}
|
|
103
|
+
for (let i = callAt; i >= 0; i--)
|
|
104
|
+
if (hasUserText(events[i]))
|
|
105
|
+
return i;
|
|
106
|
+
return Math.max(0, callAt);
|
|
107
|
+
}
|
|
@@ -26,12 +26,17 @@
|
|
|
26
26
|
* time, and an outer AbortSignal cancels it. All three abort the provider
|
|
27
27
|
* request in flight, not just the loop around it.
|
|
28
28
|
*/
|
|
29
|
+
import type { BasePlugin } from '@google/adk';
|
|
29
30
|
import type { BaseMemoryService, BaseSessionService, Event, LlmAgent } from '@google/adk';
|
|
31
|
+
import type { PendingInput } from '../workflow.ts';
|
|
32
|
+
export { describeInput } from '../workflow.ts';
|
|
33
|
+
export type { PendingInput } from '../workflow.ts';
|
|
30
34
|
import type { CompileOptions } from '../compile.ts';
|
|
31
35
|
import type { RouteResolution } from '../dispatch.ts';
|
|
32
36
|
import type { SyndicateYamlConfig } from '../loadSyndicate.ts';
|
|
37
|
+
export { ASK_USER, pendingQuestion, questionAnswerPart } from './questions.ts';
|
|
33
38
|
import type { PendingApproval } from './approvals.ts';
|
|
34
|
-
export { approvalResponsePart, pendingApproval } from './approvals.ts';
|
|
39
|
+
export { approvalResponsePart, describeApproval, pendingApproval } from './approvals.ts';
|
|
35
40
|
export type { PendingApproval } from './approvals.ts';
|
|
36
41
|
import type { TurnStopReason } from './turnControl.ts';
|
|
37
42
|
/** One part of the user's message. Text is the common case; the genai part
|
|
@@ -72,6 +77,8 @@ export interface TraceOptions {
|
|
|
72
77
|
traceId: string;
|
|
73
78
|
spanId: string;
|
|
74
79
|
}) => void;
|
|
80
|
+
/** A caller's W3C traceparent: the turn's root span links to it (never joins it). */
|
|
81
|
+
traceparent?: string;
|
|
75
82
|
}
|
|
76
83
|
export interface SyndicateTurnOptions {
|
|
77
84
|
config: SyndicateYamlConfig;
|
|
@@ -95,7 +102,7 @@ export interface SyndicateTurnOptions {
|
|
|
95
102
|
signal?: AbortSignal;
|
|
96
103
|
/** Wall-clock budget for the turn, in ms. Default: none. */
|
|
97
104
|
deadlineMs?: number;
|
|
98
|
-
/** Model-call ceiling for the whole turn. Default: the YAML's `max_steps
|
|
105
|
+
/** Model-call ceiling for the whole turn. Default: the YAML's `max_steps`, else DEFAULT_MAX_STEPS (50). */
|
|
99
106
|
maxLlmCalls?: number;
|
|
100
107
|
/** Ask adapters for token-by-token partial events (the REPL's live view). */
|
|
101
108
|
streaming?: boolean;
|
|
@@ -140,11 +147,20 @@ export interface DrainedRun {
|
|
|
140
147
|
code: string;
|
|
141
148
|
message: string;
|
|
142
149
|
};
|
|
150
|
+
/** Workflow runs: questions `ask_user` nodes raised (`adk_request_input`). */
|
|
151
|
+
inputRequests: PendingInput[];
|
|
152
|
+
/** Workflow runs: errors nodes reported along the way (a retried attempt, a failed node). */
|
|
153
|
+
nodeErrors: Array<{
|
|
154
|
+
node: string;
|
|
155
|
+
code: string;
|
|
156
|
+
message: string;
|
|
157
|
+
}>;
|
|
143
158
|
}
|
|
144
|
-
export type TurnStage = 'classify' | 'dispatch' | 'delegate';
|
|
159
|
+
export type TurnStage = 'classify' | 'dispatch' | 'delegate' | 'workflow';
|
|
145
160
|
export interface RouteDecision extends RouteResolution {
|
|
146
|
-
/** `approval`: the turn resumed the route that asked for an approval (ADR 0028).
|
|
147
|
-
|
|
161
|
+
/** `approval`: the turn resumed the route that asked for an approval (ADR 0028).
|
|
162
|
+
* `answer`: the message answered the route's open `ask_user` question. */
|
|
163
|
+
decidedBy: 'override' | 'forced' | 'classifier' | 'approval' | 'answer';
|
|
148
164
|
}
|
|
149
165
|
export interface SyndicateTurnResult {
|
|
150
166
|
/** completed: an answer was produced. failed: an error, a deadline or the
|
|
@@ -154,6 +170,9 @@ export interface SyndicateTurnResult {
|
|
|
154
170
|
/** The call waiting for approval, when status is input-required. Answer it
|
|
155
171
|
* with `approvalResponsePart(approval.id, approved)` as the next turn's part. */
|
|
156
172
|
approval?: PendingApproval;
|
|
173
|
+
/** The question a workflow's `ask_user` node asked, when status is
|
|
174
|
+
* input-required. The next message on the conversation is the answer. */
|
|
175
|
+
input?: PendingInput;
|
|
157
176
|
/** The text the user receives (relay fallback and guards applied). */
|
|
158
177
|
text: string;
|
|
159
178
|
error?: {
|
|
@@ -188,6 +207,20 @@ export interface TurnUsage {
|
|
|
188
207
|
outputTokens: number;
|
|
189
208
|
thinkingTokens: number;
|
|
190
209
|
}
|
|
210
|
+
export declare const DEFAULT_MODEL_ERROR_RETRIES = 2;
|
|
211
|
+
export declare const DEFAULT_TOOL_ERROR_RETRIES = 3;
|
|
212
|
+
/**
|
|
213
|
+
* ADK's reflect-and-retry plugins, on by default. The model plugin turns a
|
|
214
|
+
* reply ADK marks malformed (MALFORMED_FUNCTION_CALL) into a retry with
|
|
215
|
+
* guidance instead of a failed turn; the tool plugin answers a tool that
|
|
216
|
+
* threw with structured guidance and caps its retries. Every retry is a
|
|
217
|
+
* model call under the turn's max_steps. Fresh instances per run: their
|
|
218
|
+
* counters are per invocation, but a run is the unit a surface reasons in.
|
|
219
|
+
*/
|
|
220
|
+
export declare function retryPlugins(retries: {
|
|
221
|
+
model_errors?: number;
|
|
222
|
+
tool_errors?: number;
|
|
223
|
+
} | undefined): BasePlugin[];
|
|
191
224
|
/**
|
|
192
225
|
* Drains one ADK event stream into a DrainedRun. The answer is the LAST
|
|
193
226
|
* event's text, not every event's: a tool-using agent narrates between
|
|
@@ -203,6 +236,13 @@ export declare function drainAgentStream(stream: AsyncIterable<Event>, opts?: {
|
|
|
203
236
|
subagentNames?: Set<string>;
|
|
204
237
|
/** Forward partial reply text to events.onTextDelta (answering stages only). */
|
|
205
238
|
streamText?: boolean;
|
|
239
|
+
/**
|
|
240
|
+
* What an event carrying an error means. `fail` (default): the run is
|
|
241
|
+
* over, the error is the result. `collect`: note it and read on — a
|
|
242
|
+
* workflow node that will retry emits its failed attempt, and a node
|
|
243
|
+
* that gives up ends the stream itself.
|
|
244
|
+
*/
|
|
245
|
+
errorPolicy?: 'fail' | 'collect';
|
|
206
246
|
}): Promise<DrainedRun>;
|
|
207
247
|
/** True when an orchestrator's text is just the name of a tool it called —
|
|
208
248
|
* the degenerate relay ("FinancialAnalyst", 3 tokens). Deliberately narrow:
|
|
@@ -26,8 +26,11 @@
|
|
|
26
26
|
* time, and an outer AbortSignal cancels it. All three abort the provider
|
|
27
27
|
* request in flight, not just the loop around it.
|
|
28
28
|
*/
|
|
29
|
-
import { InMemorySessionService, Runner, StreamingMode, getFunctionCalls, getFunctionResponses } from '@google/adk';
|
|
30
|
-
import {
|
|
29
|
+
import { InMemorySessionService, ReflectAndRetryModelPlugin, ReflectAndRetryToolPlugin, Runner, StreamingMode, getFunctionCalls, getFunctionResponses } from '@google/adk';
|
|
30
|
+
import { DEFAULT_MAX_STEPS } from '../config.js';
|
|
31
|
+
import { agentGates, compileGraph, compileSubagent } from '../compile.js';
|
|
32
|
+
import { ROUTE_STEP_SUFFIX, compileWorkflow, describeInput, inputRequestFrom, isWorkflowSyndicate } from '../workflow.js';
|
|
33
|
+
export { describeInput } from '../workflow.js';
|
|
31
34
|
import { isDispatchSyndicate, matchRouteOverride, resolveRoute } from '../dispatch.js';
|
|
32
35
|
import { collectGrounding, describeGrounding, newGroundingState, webSourcesLine } from '../grounding.js';
|
|
33
36
|
import { resolveGuards } from '../guards/index.js';
|
|
@@ -36,13 +39,36 @@ import { registerAvailableProviders } from '../models/registry.js';
|
|
|
36
39
|
import { traceAgentRun } from '../observability/tracer.js';
|
|
37
40
|
import { ProjectedSessionService, renderTranscriptDigest } from '../session/transcript.js';
|
|
38
41
|
import { approvalDecisionIn, describeApproval, interruptedTurnStart, pendingApproval } from './approvals.js';
|
|
39
|
-
|
|
42
|
+
import { pendingQuestion, questionAnswerPart, questionFrom, turnStartOfCall } from './questions.js';
|
|
43
|
+
export { ASK_USER, pendingQuestion, questionAnswerPart } from './questions.js';
|
|
44
|
+
export { approvalResponsePart, describeApproval, pendingApproval } from './approvals.js';
|
|
40
45
|
import { RemoteA2AAgent, remoteContextId, remoteToolOutput } from '../a2a/remoteAgent.js';
|
|
41
46
|
import { createTurnControl, runWithTurnControl, stopCode, stopMessage } from './turnControl.js';
|
|
42
47
|
const THOUGHTS_PREVIEW_CHARS = 600;
|
|
43
48
|
const CLASSIFIER_PREAMBLE = `RECENT CONVERSATION, oldest first — context for your classification only. `
|
|
44
49
|
+ `Never answer it, and never route on it alone; it is here so you can tell a `
|
|
45
50
|
+ `follow-up, a redo request, or a challenge to a previous answer apart from small talk.\n`;
|
|
51
|
+
// ── Self-correction (ADR 0034) ───────────────────────────────────────────────
|
|
52
|
+
export const DEFAULT_MODEL_ERROR_RETRIES = 2;
|
|
53
|
+
export const DEFAULT_TOOL_ERROR_RETRIES = 3;
|
|
54
|
+
/**
|
|
55
|
+
* ADK's reflect-and-retry plugins, on by default. The model plugin turns a
|
|
56
|
+
* reply ADK marks malformed (MALFORMED_FUNCTION_CALL) into a retry with
|
|
57
|
+
* guidance instead of a failed turn; the tool plugin answers a tool that
|
|
58
|
+
* threw with structured guidance and caps its retries. Every retry is a
|
|
59
|
+
* model call under the turn's max_steps. Fresh instances per run: their
|
|
60
|
+
* counters are per invocation, but a run is the unit a surface reasons in.
|
|
61
|
+
*/
|
|
62
|
+
export function retryPlugins(retries) {
|
|
63
|
+
const model = retries?.model_errors ?? DEFAULT_MODEL_ERROR_RETRIES;
|
|
64
|
+
const tool = retries?.tool_errors ?? DEFAULT_TOOL_ERROR_RETRIES;
|
|
65
|
+
const plugins = [];
|
|
66
|
+
if (model > 0)
|
|
67
|
+
plugins.push(new ReflectAndRetryModelPlugin({ maxRetries: model }));
|
|
68
|
+
if (tool > 0)
|
|
69
|
+
plugins.push(new ReflectAndRetryToolPlugin({ maxRetries: tool, throwExceptionIfRetryExceeded: false }));
|
|
70
|
+
return plugins;
|
|
71
|
+
}
|
|
46
72
|
// ── Draining one agent's stream ──────────────────────────────────────────────
|
|
47
73
|
function formatToolArgs(args) {
|
|
48
74
|
if (!args || Object.keys(args).length === 0)
|
|
@@ -75,10 +101,13 @@ export async function drainAgentStream(stream, opts = {}) {
|
|
|
75
101
|
thoughts: '',
|
|
76
102
|
tokens: { input: 0, output: 0, thinking: 0 },
|
|
77
103
|
eventCount: 0,
|
|
104
|
+
inputRequests: [],
|
|
105
|
+
nodeErrors: [],
|
|
78
106
|
};
|
|
79
107
|
const grounding = newGroundingState();
|
|
80
108
|
let groundingAnnounced = false;
|
|
81
109
|
let streamed = false;
|
|
110
|
+
let lastNode = '';
|
|
82
111
|
for await (const event of stream) {
|
|
83
112
|
const e = event;
|
|
84
113
|
d.eventCount++;
|
|
@@ -95,8 +124,24 @@ export async function drainAgentStream(stream, opts = {}) {
|
|
|
95
124
|
ev.onProgress?.('Invoking tool: web_search');
|
|
96
125
|
groundingAnnounced = true;
|
|
97
126
|
}
|
|
127
|
+
// A workflow event names its node; say when the graph moves on. The
|
|
128
|
+
// root (a path with no dot) and the hidden route steps are not nodes a
|
|
129
|
+
// person declared, so they stay out of the progress.
|
|
130
|
+
const nodePath = e.nodeInfo?.path;
|
|
131
|
+
if (nodePath && nodePath.includes('.') && e.author && !String(e.author).endsWith(ROUTE_STEP_SUFFIX) && e.author !== lastNode && e.partial !== true) {
|
|
132
|
+
lastNode = e.author;
|
|
133
|
+
ev.log?.(`⇢ Node: ${e.author}`);
|
|
134
|
+
if (opts.publishToolStatus)
|
|
135
|
+
ev.onProgress?.(`Running node: ${e.author}`);
|
|
136
|
+
}
|
|
98
137
|
if ((e.errorCode || e.errorMessage) && e.errorCode !== 'STOP') {
|
|
99
|
-
|
|
138
|
+
const error = { code: String(e.errorCode ?? 'ERROR'), message: String(e.errorMessage ?? '') };
|
|
139
|
+
if (opts.errorPolicy === 'collect') {
|
|
140
|
+
d.nodeErrors.push({ node: String(e.author ?? ''), ...error });
|
|
141
|
+
ev.warn?.(`Node ${e.author ?? '?'} reported [${error.code}]: ${error.message}`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
d.error = error;
|
|
100
145
|
ev.warn?.(`Error [${d.error.code}]: ${d.error.message}`);
|
|
101
146
|
break;
|
|
102
147
|
}
|
|
@@ -123,6 +168,13 @@ export async function drainAgentStream(stream, opts = {}) {
|
|
|
123
168
|
ev.onProgress?.(`Delegating to subagent: ${target}`);
|
|
124
169
|
continue;
|
|
125
170
|
}
|
|
171
|
+
const inputRequest = inputRequestFrom(e.author, call) ?? questionFrom(e.author, call);
|
|
172
|
+
if (inputRequest) {
|
|
173
|
+
// An ask_user node: the workflow waits for the person (lib/workflow.ts).
|
|
174
|
+
d.inputRequests.push(inputRequest);
|
|
175
|
+
ev.log?.(`⏸ ${describeInput(inputRequest)}`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
126
178
|
if (!name)
|
|
127
179
|
continue;
|
|
128
180
|
d.invokedToolNames.add(name);
|
|
@@ -214,7 +266,7 @@ export async function runSyndicateTurn(opts) {
|
|
|
214
266
|
if (!opts.compile?.resolveModel)
|
|
215
267
|
registerAvailableProviders();
|
|
216
268
|
const control = createTurnControl({
|
|
217
|
-
maxLlmCalls: opts.maxLlmCalls ?? config.max_steps,
|
|
269
|
+
maxLlmCalls: opts.maxLlmCalls ?? config.max_steps ?? DEFAULT_MAX_STEPS,
|
|
218
270
|
deadlineMs: opts.deadlineMs,
|
|
219
271
|
signal: opts.signal,
|
|
220
272
|
});
|
|
@@ -234,7 +286,7 @@ async function runTurnInner(opts, control) {
|
|
|
234
286
|
const transform = opts.transformAgent ?? ((a) => a);
|
|
235
287
|
const subagentNames = new Set((config.subagents ?? []).map((s) => s.name));
|
|
236
288
|
const trace = opts.trace === false ? undefined : opts.trace ?? {};
|
|
237
|
-
|
|
289
|
+
let parts = opts.parts.map((p) => (typeof p === 'string' ? { text: p } : p));
|
|
238
290
|
const messageText = parts.map((p) => (typeof p.text === 'string' ? p.text : '')).join('\n');
|
|
239
291
|
const result = {
|
|
240
292
|
status: 'completed',
|
|
@@ -286,12 +338,39 @@ async function runTurnInner(opts, control) {
|
|
|
286
338
|
// The log names the call, not its arguments: they are user content.
|
|
287
339
|
ev.log?.(`✓ Approval ${decision.approved ? 'granted' : 'refused'}: ${resuming.agent} → ${resuming.tool}`);
|
|
288
340
|
}
|
|
341
|
+
// ── Questions (lib/runtime/questions.ts) ───────────────────────────────────
|
|
342
|
+
// While an agent's `ask_user` call is open, a plain-text message is its
|
|
343
|
+
// answer: it becomes that call's response, and the agent that asked
|
|
344
|
+
// resumes its own tool loop. A workflow's pauses are ADK's own business.
|
|
345
|
+
let answering;
|
|
346
|
+
if (!decision && !isWorkflowSyndicate(config)) {
|
|
347
|
+
const question = pendingQuestion(existing?.events ?? []);
|
|
348
|
+
const plainText = parts.length > 0 && parts.every((p) => typeof p.text === 'string');
|
|
349
|
+
if (question && plainText) {
|
|
350
|
+
answering = { agent: question.node, id: question.id };
|
|
351
|
+
parts = [questionAnswerPart(question.id, messageText)];
|
|
352
|
+
ev.log?.(`✓ Answer to ${question.node}'s question`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** The agent a dispatch turn must resume, and the call its interrupted turn holds. */
|
|
356
|
+
const resumeTarget = resuming ? { agent: resuming.agent, id: resuming.id, why: 'resuming an approval' } : answering ? { ...answering, why: 'answering its question' } : undefined;
|
|
289
357
|
/** After the answering run: is a gated call now waiting? */
|
|
290
358
|
const awaitingApproval = async (agentName) => {
|
|
291
359
|
const after = await sessionService.getSession({ appName, userId, sessionId });
|
|
292
360
|
const pending = pendingApproval(after?.events ?? []);
|
|
293
361
|
return pending && pending.agent === agentName ? pending : undefined;
|
|
294
362
|
};
|
|
363
|
+
/** After the answering run: did the agent ask the person something? */
|
|
364
|
+
const asked = (run) => {
|
|
365
|
+
const input = run.inputRequests[run.inputRequests.length - 1];
|
|
366
|
+
if (!input)
|
|
367
|
+
return undefined;
|
|
368
|
+
result.status = 'input-required';
|
|
369
|
+
result.input = input;
|
|
370
|
+
result.text = input.message;
|
|
371
|
+
ev.log?.(`⏸ Input needed: ${describeInput(input)}`);
|
|
372
|
+
return finish();
|
|
373
|
+
};
|
|
295
374
|
const pause = (pending) => {
|
|
296
375
|
result.status = 'input-required';
|
|
297
376
|
result.approval = pending;
|
|
@@ -305,6 +384,7 @@ async function runTurnInner(opts, control) {
|
|
|
305
384
|
agent: params.agent,
|
|
306
385
|
appName,
|
|
307
386
|
sessionService: params.sessions,
|
|
387
|
+
plugins: retryPlugins(config.retries),
|
|
308
388
|
...(opts.memoryService ? { memoryService: opts.memoryService } : {}),
|
|
309
389
|
});
|
|
310
390
|
let stream = runner.runAsync({
|
|
@@ -329,6 +409,7 @@ async function runTurnInner(opts, control) {
|
|
|
329
409
|
configHash: trace.configHash,
|
|
330
410
|
attributes: trace.attributes,
|
|
331
411
|
onSpanStart: trace.onSpanStart,
|
|
412
|
+
traceparent: trace.traceparent,
|
|
332
413
|
onEnd: () => ({
|
|
333
414
|
'syndicate.relay_fallback': params.stage === 'delegate' && !!params.relayFallback?.(),
|
|
334
415
|
...(control.stopReason ? { 'syndicate.stop_reason': control.stopReason } : {}),
|
|
@@ -342,6 +423,7 @@ async function runTurnInner(opts, control) {
|
|
|
342
423
|
publishToolStatus: params.publishToolStatus,
|
|
343
424
|
subagentNames,
|
|
344
425
|
streamText: params.stage !== 'classify' && !guarded,
|
|
426
|
+
errorPolicy: params.errorPolicy,
|
|
345
427
|
});
|
|
346
428
|
}
|
|
347
429
|
catch (err) {
|
|
@@ -359,16 +441,16 @@ async function runTurnInner(opts, control) {
|
|
|
359
441
|
// Deterministic overrides first: when the message itself decides the
|
|
360
442
|
// route there is nothing to classify, and the classifier call is skipped.
|
|
361
443
|
const warnings = [];
|
|
362
|
-
let resolution =
|
|
444
|
+
let resolution = resumeTarget ? null : matchRouteOverride(messageText, config, warnings);
|
|
363
445
|
let decidedBy = 'override';
|
|
364
446
|
for (const w of warnings)
|
|
365
447
|
ev.warn?.(w);
|
|
366
|
-
if (
|
|
367
|
-
if (!subagentNames.has(
|
|
368
|
-
throw new Error(`
|
|
448
|
+
if (resumeTarget) {
|
|
449
|
+
if (!subagentNames.has(resumeTarget.agent)) {
|
|
450
|
+
throw new Error(`Call ${resumeTarget.id} was raised by '${resumeTarget.agent}', which is not a route of this syndicate.`);
|
|
369
451
|
}
|
|
370
|
-
resolution = { route:
|
|
371
|
-
decidedBy = 'approval';
|
|
452
|
+
resolution = { route: resumeTarget.agent, reason: resumeTarget.why, fellBack: false, fallbackReason: '', viaOverride: false };
|
|
453
|
+
decidedBy = resuming ? 'approval' : 'answer';
|
|
372
454
|
}
|
|
373
455
|
else if (resolution) {
|
|
374
456
|
ev.log?.(`⇄ Route pinned by override: ${resolution.route}`);
|
|
@@ -437,9 +519,13 @@ async function runTurnInner(opts, control) {
|
|
|
437
519
|
agent: routeAgent,
|
|
438
520
|
sid: sessionId,
|
|
439
521
|
userParts: parts,
|
|
440
|
-
// Resuming an approval: the interrupted turn
|
|
441
|
-
// finds the call the
|
|
442
|
-
sessions: new ProjectedSessionService(sessionService, routeCfg.name, resuming
|
|
522
|
+
// Resuming an approval or answering a question: the interrupted turn
|
|
523
|
+
// is replayed raw, so ADK finds the call the message answers.
|
|
524
|
+
sessions: new ProjectedSessionService(sessionService, routeCfg.name, resuming
|
|
525
|
+
? { rawFrom: (events) => interruptedTurnStart(events, resuming.id) }
|
|
526
|
+
: answering
|
|
527
|
+
? { rawFrom: (events) => turnStartOfCall(events, answering.id) }
|
|
528
|
+
: {}),
|
|
443
529
|
stage: 'dispatch',
|
|
444
530
|
route: resolution,
|
|
445
531
|
publishToolStatus: true,
|
|
@@ -452,11 +538,14 @@ async function runTurnInner(opts, control) {
|
|
|
452
538
|
result.error = answer.error;
|
|
453
539
|
return finish();
|
|
454
540
|
}
|
|
455
|
-
if (!routeCfg.a2a_agent_url && routeCfg
|
|
541
|
+
if (!routeCfg.a2a_agent_url && agentGates(routeCfg)) {
|
|
456
542
|
const pending = await awaitingApproval(routeCfg.name);
|
|
457
543
|
if (pending)
|
|
458
544
|
return pause(pending);
|
|
459
545
|
}
|
|
546
|
+
const askedRoute = asked(answer);
|
|
547
|
+
if (askedRoute)
|
|
548
|
+
return askedRoute;
|
|
460
549
|
result.text = answer.text;
|
|
461
550
|
if (!result.text) {
|
|
462
551
|
// Naming the route turns a blank reply into a lead (the XScout outage
|
|
@@ -466,6 +555,60 @@ async function runTurnInner(opts, control) {
|
|
|
466
555
|
result.text = `${resolution.route} returned no output — the server logs carry the upstream error.`;
|
|
467
556
|
}
|
|
468
557
|
}
|
|
558
|
+
else if (isWorkflowSyndicate(config)) {
|
|
559
|
+
// ══ WORKFLOW ═════════════════════════════════════════════════════════
|
|
560
|
+
// The syndicate is a graph (lib/workflow.ts): every agent a node, run
|
|
561
|
+
// by ADK's Workflow in the shared session. A node agent sees only its
|
|
562
|
+
// input unless its YAML says otherwise, so no projection is needed. An
|
|
563
|
+
// `ask_user` node ends the turn input-required; the next message
|
|
564
|
+
// resumes the graph where it waited.
|
|
565
|
+
const compiled = await compileWorkflow(config, compileOpts, transform);
|
|
566
|
+
try {
|
|
567
|
+
answer = await runAgent({
|
|
568
|
+
agent: compiled.workflow,
|
|
569
|
+
sid: sessionId,
|
|
570
|
+
userParts: parts,
|
|
571
|
+
sessions: sessionService,
|
|
572
|
+
stage: 'workflow',
|
|
573
|
+
publishToolStatus: true,
|
|
574
|
+
errorPolicy: 'collect',
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
// A node that gave up (its retries spent, a timeout, a schema it could
|
|
579
|
+
// not satisfy) ends the run by throwing; the last reported error names it.
|
|
580
|
+
const last = control.stopReason ? undefined : err;
|
|
581
|
+
if (!last)
|
|
582
|
+
throw err;
|
|
583
|
+
result.status = 'failed';
|
|
584
|
+
result.failedStage = 'workflow';
|
|
585
|
+
result.error = { code: 'NODE_FAILED', message: last.message };
|
|
586
|
+
return finish();
|
|
587
|
+
}
|
|
588
|
+
result.answer = answer;
|
|
589
|
+
if (answer.error || control.stopReason) {
|
|
590
|
+
result.status = 'failed';
|
|
591
|
+
result.failedStage = 'workflow';
|
|
592
|
+
result.error = answer.error;
|
|
593
|
+
return finish();
|
|
594
|
+
}
|
|
595
|
+
if (answer.inputRequests.length) {
|
|
596
|
+
const input = answer.inputRequests[answer.inputRequests.length - 1];
|
|
597
|
+
result.status = 'input-required';
|
|
598
|
+
result.input = input;
|
|
599
|
+
result.text = input.message;
|
|
600
|
+
ev.log?.(`⏸ Input needed: ${describeInput(input)}`);
|
|
601
|
+
return finish();
|
|
602
|
+
}
|
|
603
|
+
result.text = answer.text;
|
|
604
|
+
if (!result.text) {
|
|
605
|
+
const failed = answer.nodeErrors[answer.nodeErrors.length - 1];
|
|
606
|
+
ev.warn?.(`The workflow produced no output${failed ? ` (last node error: ${failed.node} [${failed.code}] ${failed.message})` : ''}.`);
|
|
607
|
+
result.text = failed
|
|
608
|
+
? `The workflow ended without an answer — ${failed.node} failed [${failed.code}]: ${failed.message}`
|
|
609
|
+
: 'The workflow ended without an answer.';
|
|
610
|
+
}
|
|
611
|
+
}
|
|
469
612
|
else {
|
|
470
613
|
// ══ DELEGATE ═════════════════════════════════════════════════════════
|
|
471
614
|
// Subagents are AgentTools; the orchestrator relays the answer it got.
|
|
@@ -488,11 +631,14 @@ async function runTurnInner(opts, control) {
|
|
|
488
631
|
result.error = answer.error;
|
|
489
632
|
return finish();
|
|
490
633
|
}
|
|
491
|
-
if (config.orchestrator
|
|
634
|
+
if (agentGates(config.orchestrator)) {
|
|
492
635
|
const pending = await awaitingApproval(config.orchestrator.name);
|
|
493
636
|
if (pending)
|
|
494
637
|
return pause(pending);
|
|
495
638
|
}
|
|
639
|
+
const askedOrchestrator = asked(answer);
|
|
640
|
+
if (askedOrchestrator)
|
|
641
|
+
return askedOrchestrator;
|
|
496
642
|
result.text = answer.text;
|
|
497
643
|
// Failed-relay fallback: an orchestrator can botch the hop that relays a
|
|
498
644
|
// specialist's answer — STOP with no text, or the bare tool NAME as its
|
|
@@ -563,6 +709,8 @@ async function runRemoteRoute(name, url, messageText, sessionId, control, ev) {
|
|
|
563
709
|
}
|
|
564
710
|
function emptyRun(error) {
|
|
565
711
|
return {
|
|
712
|
+
inputRequests: [],
|
|
713
|
+
nodeErrors: [],
|
|
566
714
|
text: '',
|
|
567
715
|
lastToolResultText: '',
|
|
568
716
|
toolResultTexts: [],
|
|
@@ -17,6 +17,8 @@ import type { Embedder, MemoryExtractor } from '../../memory/providers.ts';
|
|
|
17
17
|
import type { EraseCounts, EraseOptions } from '../../memory/erase.ts';
|
|
18
18
|
import { PostgresSessionService } from './sessionService.ts';
|
|
19
19
|
import { PostgresTaskStore } from './taskStore.ts';
|
|
20
|
+
import type { AuditSink } from '../../observability/audit.ts';
|
|
21
|
+
import type { RlsHardeningStatus } from '../rlsStatus.ts';
|
|
20
22
|
import type { TaskBackend } from '../../tools/taskTools.ts';
|
|
21
23
|
import type { TurnLock } from '../../a2a/turnLock.ts';
|
|
22
24
|
import type { PostgresTaskStoreOptions } from './taskStore.ts';
|
|
@@ -70,6 +72,13 @@ export interface PostgresStorage {
|
|
|
70
72
|
erase: (scopeKey: string, options?: EraseOptions) => Promise<EraseCounts>;
|
|
71
73
|
/** The highest migration recorded in melchizedek_schema_version; null if none. */
|
|
72
74
|
schemaVersion: () => Promise<number | null>;
|
|
75
|
+
/**
|
|
76
|
+
* Whether db/hardening.sql is in force where an API could expose the
|
|
77
|
+
* tables (lib/storage/rlsStatus.ts). Never throws.
|
|
78
|
+
*/
|
|
79
|
+
rlsHardening: () => Promise<RlsHardeningStatus>;
|
|
80
|
+
/** Appends to melchizedek_audit (migration 0012, ADR 0042). */
|
|
81
|
+
audit: AuditSink;
|
|
73
82
|
/**
|
|
74
83
|
* One turn at a time per conversation, across every instance on this
|
|
75
84
|
* database: a session-level advisory lock on a dedicated connection, so a
|
|
@@ -19,6 +19,8 @@ import { PostgresSessionService } from './sessionService.js';
|
|
|
19
19
|
import { PostgresTaskStore, reapExpiredTasks, renewTaskLeases } from './taskStore.js';
|
|
20
20
|
import { postgresTaskBackend } from './taskQueue.js';
|
|
21
21
|
import { searchPathOption } from '../schema.js';
|
|
22
|
+
import { POSTGRES_RLS_QUERY, evaluatePostgresRls } from '../rlsStatus.js';
|
|
23
|
+
import { postgresAuditSink } from '../../observability/audit.js';
|
|
22
24
|
import { createHash, randomUUID } from 'node:crypto';
|
|
23
25
|
import { hostname } from 'node:os';
|
|
24
26
|
export { PostgresSessionService } from './sessionService.js';
|
|
@@ -121,6 +123,17 @@ export function postgresStorage(options) {
|
|
|
121
123
|
throw err;
|
|
122
124
|
}
|
|
123
125
|
},
|
|
126
|
+
audit: postgresAuditSink(pool),
|
|
127
|
+
async rlsHardening() {
|
|
128
|
+
try {
|
|
129
|
+
const r = await pool.query(POSTGRES_RLS_QUERY);
|
|
130
|
+
const row = r.rows[0];
|
|
131
|
+
return evaluatePostgresRls({ apiRoles: row.api_roles, schema: row.schema, rows: row.rows });
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
return { applied: false, detail: `hardening check failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
135
|
+
}
|
|
136
|
+
},
|
|
124
137
|
async erase(scopeKey, eraseOptions = {}) {
|
|
125
138
|
if (!scopeKey?.trim())
|
|
126
139
|
throw new Error('erase: a scope key is required');
|