@flink-app/flink 2.0.0-alpha.91 → 2.0.0-alpha.93
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/CHANGELOG.md +47 -0
- package/dist/src/FlinkApp.d.ts +17 -0
- package/dist/src/FlinkApp.js +4 -2
- package/dist/src/ai/AgentRunner.d.ts +9 -2
- package/dist/src/ai/AgentRunner.js +507 -363
- package/dist/src/ai/FlinkAgent.d.ts +100 -1
- package/dist/src/ai/FlinkAgent.js +16 -3
- package/package.json +1 -1
- package/spec/AgentObserver.spec.ts +266 -0
- package/src/FlinkApp.ts +22 -1
- package/src/ai/AgentRunner.ts +141 -15
- package/src/ai/FlinkAgent.ts +112 -2
|
@@ -230,6 +230,7 @@ export declare abstract class FlinkAgent<Ctx extends FlinkContext, ConversationC
|
|
|
230
230
|
private _boundConversationContext?;
|
|
231
231
|
private _llmAdapters?;
|
|
232
232
|
private _tools?;
|
|
233
|
+
private _observer?;
|
|
233
234
|
abstract id: string;
|
|
234
235
|
abstract description: string;
|
|
235
236
|
/**
|
|
@@ -294,7 +295,7 @@ export declare abstract class FlinkAgent<Ctx extends FlinkContext, ConversationC
|
|
|
294
295
|
*/
|
|
295
296
|
__init(llmAdapters: Map<string, any>, tools: {
|
|
296
297
|
[x: string]: ToolExecutor<Ctx>;
|
|
297
|
-
}): void;
|
|
298
|
+
}, observer?: AgentObserver): void;
|
|
298
299
|
/**
|
|
299
300
|
* Bind a user to this agent for permission checks
|
|
300
301
|
*
|
|
@@ -539,6 +540,12 @@ export interface AgentExecuteInput<ConversationCtx = any> {
|
|
|
539
540
|
};
|
|
540
541
|
}
|
|
541
542
|
export interface AgentExecuteResult {
|
|
543
|
+
/**
|
|
544
|
+
* Framework-generated unique ID for this agent execution.
|
|
545
|
+
* Matches the `runId` emitted in AgentObserver events, enabling apps to
|
|
546
|
+
* correlate persisted results with observer traces.
|
|
547
|
+
*/
|
|
548
|
+
runId: string;
|
|
542
549
|
message: string;
|
|
543
550
|
toolCalls: Array<{
|
|
544
551
|
name: string;
|
|
@@ -589,3 +596,95 @@ export interface AgentResponse {
|
|
|
589
596
|
textStream: AsyncGenerator<string>;
|
|
590
597
|
fullStream: AsyncGenerator<StreamChunk>;
|
|
591
598
|
}
|
|
599
|
+
/**
|
|
600
|
+
* Event fired once per agent execution, before the first LLM call.
|
|
601
|
+
* Contains the resolved system instructions and the initial message array
|
|
602
|
+
* (post-history, pre-compaction, pre-tool-filtering).
|
|
603
|
+
*/
|
|
604
|
+
export interface AgentObserverRunEvent {
|
|
605
|
+
runId: string;
|
|
606
|
+
agentId: string;
|
|
607
|
+
instructions: string;
|
|
608
|
+
input: AgentExecuteInput;
|
|
609
|
+
messages: LLMMessage[];
|
|
610
|
+
tools: string[];
|
|
611
|
+
model: {
|
|
612
|
+
adapterId?: string;
|
|
613
|
+
maxTokens?: number;
|
|
614
|
+
temperature?: number;
|
|
615
|
+
};
|
|
616
|
+
context: AgentExecuteContext;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Event fired immediately before each LLM call in the agentic loop.
|
|
620
|
+
* Reflects the messages and tools actually sent to the model after
|
|
621
|
+
* compaction and per-step permission filtering.
|
|
622
|
+
*/
|
|
623
|
+
export interface AgentObserverLlmCallEvent {
|
|
624
|
+
runId: string;
|
|
625
|
+
agentId: string;
|
|
626
|
+
step: number;
|
|
627
|
+
maxSteps: number;
|
|
628
|
+
instructions: string;
|
|
629
|
+
messages: LLMMessage[];
|
|
630
|
+
tools: string[];
|
|
631
|
+
model: {
|
|
632
|
+
adapterId?: string;
|
|
633
|
+
maxTokens?: number;
|
|
634
|
+
temperature?: number;
|
|
635
|
+
};
|
|
636
|
+
context: AgentExecuteContext;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Event fired after each step (LLM call + tool executions) completes.
|
|
640
|
+
* `toolCalls` contains only the tool calls executed during this step.
|
|
641
|
+
*/
|
|
642
|
+
export interface AgentObserverStepEvent {
|
|
643
|
+
runId: string;
|
|
644
|
+
agentId: string;
|
|
645
|
+
step: number;
|
|
646
|
+
maxSteps: number;
|
|
647
|
+
messages: LLMMessage[];
|
|
648
|
+
assistantText?: string;
|
|
649
|
+
toolCalls: AgentExecuteResult["toolCalls"];
|
|
650
|
+
usage?: import("./LLMAdapter").LLMUsage;
|
|
651
|
+
context: AgentExecuteContext;
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Event fired when an agent execution completes (successfully or with error).
|
|
655
|
+
* On error, `result` contains whatever was accumulated before the throw.
|
|
656
|
+
*/
|
|
657
|
+
export interface AgentObserverFinishEvent {
|
|
658
|
+
runId: string;
|
|
659
|
+
agentId: string;
|
|
660
|
+
result: AgentExecuteResult;
|
|
661
|
+
messages: LLMMessage[];
|
|
662
|
+
durationMs: number;
|
|
663
|
+
error?: string;
|
|
664
|
+
context: AgentExecuteContext;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Global agent observer for app-level tracing, APM, cost accounting, dev tools, etc.
|
|
668
|
+
*
|
|
669
|
+
* Register once on `FlinkOptions.ai.observer`; fires for every agent execution in the app.
|
|
670
|
+
*
|
|
671
|
+
* Observer callbacks are invoked fire-and-forget: they may return a Promise but the
|
|
672
|
+
* framework does not await them, and any thrown/rejected errors are caught and logged
|
|
673
|
+
* without affecting agent execution. Observers are read-only — they must not mutate
|
|
674
|
+
* inputs, messages, or results. Use the per-agent `beforeRun`/`onStep`/`afterRun` hooks
|
|
675
|
+
* for business logic that needs to block or mutate.
|
|
676
|
+
*
|
|
677
|
+
* Typical use cases:
|
|
678
|
+
* - Persisted traces for a dev tools page (correlate via `context.metadata`)
|
|
679
|
+
* - OpenTelemetry / Sentry integration
|
|
680
|
+
* - Cost accounting and token-usage dashboards
|
|
681
|
+
*
|
|
682
|
+
* All events share a stable `runId` per execution so persisted records can be joined
|
|
683
|
+
* across events. The same `runId` is returned on `AgentExecuteResult.runId`.
|
|
684
|
+
*/
|
|
685
|
+
export interface AgentObserver {
|
|
686
|
+
onRun?(event: AgentObserverRunEvent): void | Promise<void>;
|
|
687
|
+
onLlmCall?(event: AgentObserverLlmCallEvent): void | Promise<void>;
|
|
688
|
+
onStep?(event: AgentObserverStepEvent): void | Promise<void>;
|
|
689
|
+
onFinish?(event: AgentObserverFinishEvent): void | Promise<void>;
|
|
690
|
+
}
|
|
@@ -162,9 +162,10 @@ var FlinkAgent = /** @class */ (function () {
|
|
|
162
162
|
* Internal initialization called by FlinkApp
|
|
163
163
|
* @internal
|
|
164
164
|
*/
|
|
165
|
-
FlinkAgent.prototype.__init = function (llmAdapters, tools) {
|
|
165
|
+
FlinkAgent.prototype.__init = function (llmAdapters, tools, observer) {
|
|
166
166
|
this._llmAdapters = llmAdapters;
|
|
167
167
|
this._tools = tools;
|
|
168
|
+
this._observer = observer;
|
|
168
169
|
};
|
|
169
170
|
/**
|
|
170
171
|
* Bind a user to this agent for permission checks
|
|
@@ -196,6 +197,9 @@ var FlinkAgent = /** @class */ (function () {
|
|
|
196
197
|
if (this._tools) {
|
|
197
198
|
bound._tools = this._tools;
|
|
198
199
|
}
|
|
200
|
+
if (this._observer) {
|
|
201
|
+
bound._observer = this._observer;
|
|
202
|
+
}
|
|
199
203
|
if (this._boundUserPermissions !== undefined) {
|
|
200
204
|
bound._boundUserPermissions = this._boundUserPermissions;
|
|
201
205
|
}
|
|
@@ -234,6 +238,9 @@ var FlinkAgent = /** @class */ (function () {
|
|
|
234
238
|
if (this._tools) {
|
|
235
239
|
bound._tools = this._tools;
|
|
236
240
|
}
|
|
241
|
+
if (this._observer) {
|
|
242
|
+
bound._observer = this._observer;
|
|
243
|
+
}
|
|
237
244
|
if (this._boundUser !== undefined) {
|
|
238
245
|
bound._boundUser = this._boundUser;
|
|
239
246
|
}
|
|
@@ -271,6 +278,9 @@ var FlinkAgent = /** @class */ (function () {
|
|
|
271
278
|
if (this._tools) {
|
|
272
279
|
bound._tools = this._tools;
|
|
273
280
|
}
|
|
281
|
+
if (this._observer) {
|
|
282
|
+
bound._observer = this._observer;
|
|
283
|
+
}
|
|
274
284
|
if (this._boundUser !== undefined) {
|
|
275
285
|
bound._boundUser = this._boundUser;
|
|
276
286
|
}
|
|
@@ -310,6 +320,9 @@ var FlinkAgent = /** @class */ (function () {
|
|
|
310
320
|
if (this._tools) {
|
|
311
321
|
bound._tools = this._tools;
|
|
312
322
|
}
|
|
323
|
+
if (this._observer) {
|
|
324
|
+
bound._observer = this._observer;
|
|
325
|
+
}
|
|
313
326
|
if (this._boundUser !== undefined) {
|
|
314
327
|
bound._boundUser = this._boundUser;
|
|
315
328
|
}
|
|
@@ -606,8 +619,8 @@ var FlinkAgent = /** @class */ (function () {
|
|
|
606
619
|
// Get tools map and LLM adapters from internal properties
|
|
607
620
|
var toolsMap = this.resolveTools();
|
|
608
621
|
var llmAdapters = this._llmAdapters;
|
|
609
|
-
this.runner = new AgentRunner_1.AgentRunner(this.toAgentProps(), toolsMap, llmAdapters, this.getAgentId(), this.ctx // Pass ctx to runner so callbacks can access it
|
|
610
|
-
);
|
|
622
|
+
this.runner = new AgentRunner_1.AgentRunner(this.toAgentProps(), toolsMap, llmAdapters, this.getAgentId(), this.ctx, // Pass ctx to runner so callbacks can access it
|
|
623
|
+
this._observer);
|
|
611
624
|
}
|
|
612
625
|
return this.runner;
|
|
613
626
|
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
AgentExecuteInput,
|
|
4
|
+
AgentObserver,
|
|
5
|
+
FlinkAgent,
|
|
6
|
+
} from "../src/ai/FlinkAgent";
|
|
7
|
+
import { FlinkToolProps } from "../src/ai/FlinkTool";
|
|
8
|
+
import { LLMAdapter, LLMMessage, LLMStreamChunk } from "../src/ai/LLMAdapter";
|
|
9
|
+
import { ToolExecutor } from "../src/ai/ToolExecutor";
|
|
10
|
+
import { FlinkContext } from "../src/FlinkContext";
|
|
11
|
+
import { createStreamingMock } from "./testHelpers";
|
|
12
|
+
|
|
13
|
+
function makeAgent(opts: {
|
|
14
|
+
adapter: LLMAdapter;
|
|
15
|
+
tools?: { [id: string]: ToolExecutor<any> };
|
|
16
|
+
declaredToolNames?: string[];
|
|
17
|
+
observer?: AgentObserver;
|
|
18
|
+
compact?: boolean;
|
|
19
|
+
ctx?: FlinkContext;
|
|
20
|
+
permissions?: string | string[] | ((user?: any) => boolean);
|
|
21
|
+
}) {
|
|
22
|
+
const ctx: FlinkContext = opts.ctx ?? { repos: {}, plugins: {}, agents: {} };
|
|
23
|
+
const declared = opts.declaredToolNames ?? Object.keys(opts.tools ?? {});
|
|
24
|
+
|
|
25
|
+
class TestAgent extends FlinkAgent<FlinkContext> {
|
|
26
|
+
id = "test-agent";
|
|
27
|
+
description = "Test agent";
|
|
28
|
+
instructions() {
|
|
29
|
+
return "Test instructions";
|
|
30
|
+
}
|
|
31
|
+
tools: string[] = declared;
|
|
32
|
+
|
|
33
|
+
permissions = opts.permissions;
|
|
34
|
+
|
|
35
|
+
// Force compaction to a single-message window to verify onLlmCall sees post-compaction state
|
|
36
|
+
protected shouldCompact = opts.compact ? () => true : undefined;
|
|
37
|
+
protected compactHistory = opts.compact ? (msgs: LLMMessage[]) => msgs.slice(-1) : undefined;
|
|
38
|
+
|
|
39
|
+
async query(input: AgentExecuteInput) {
|
|
40
|
+
const response = this.execute(input);
|
|
41
|
+
return await response.result;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const agent = new TestAgent();
|
|
46
|
+
(agent as any).ctx = ctx;
|
|
47
|
+
agent.__init(
|
|
48
|
+
new Map([["default", opts.adapter]]),
|
|
49
|
+
opts.tools ?? {},
|
|
50
|
+
opts.observer
|
|
51
|
+
);
|
|
52
|
+
return agent;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("AgentObserver", () => {
|
|
56
|
+
let mockCtx: FlinkContext;
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
mockCtx = { repos: {}, plugins: {}, agents: {} };
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("fires onRun once with resolved instructions and initial messages", async () => {
|
|
63
|
+
const onRun = jasmine.createSpy("onRun");
|
|
64
|
+
const adapter = createStreamingMock([
|
|
65
|
+
{ textContent: "Hello", toolCalls: [], usage: { inputTokens: 1, outputTokens: 2 }, stopReason: "end_turn" },
|
|
66
|
+
]);
|
|
67
|
+
const agent = makeAgent({ adapter, observer: { onRun }, ctx: mockCtx });
|
|
68
|
+
|
|
69
|
+
await (agent as any).query({ message: "hi" });
|
|
70
|
+
|
|
71
|
+
expect(onRun).toHaveBeenCalledTimes(1);
|
|
72
|
+
const event = onRun.calls.mostRecent().args[0];
|
|
73
|
+
expect(event.agentId).toBe("test-agent");
|
|
74
|
+
expect(event.instructions).toBe("Test instructions");
|
|
75
|
+
expect(event.messages.length).toBe(1);
|
|
76
|
+
expect(event.messages[0].content).toBe("hi");
|
|
77
|
+
expect(typeof event.runId).toBe("string");
|
|
78
|
+
expect(event.runId.length).toBeGreaterThan(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("fires onLlmCall per step with post-compaction messages", async () => {
|
|
82
|
+
const onLlmCall = jasmine.createSpy("onLlmCall");
|
|
83
|
+
const adapter = createStreamingMock([
|
|
84
|
+
{ textContent: "done", toolCalls: [], usage: { inputTokens: 1, outputTokens: 2 }, stopReason: "end_turn" },
|
|
85
|
+
]);
|
|
86
|
+
const agent = makeAgent({ adapter, observer: { onLlmCall }, compact: true, ctx: mockCtx });
|
|
87
|
+
|
|
88
|
+
await (agent as any).query({
|
|
89
|
+
message: "new",
|
|
90
|
+
history: [
|
|
91
|
+
{ role: "user", content: "1" },
|
|
92
|
+
{ role: "assistant", content: "2" },
|
|
93
|
+
{ role: "user", content: "3" },
|
|
94
|
+
],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(onLlmCall).toHaveBeenCalledTimes(1);
|
|
98
|
+
const event = onLlmCall.calls.mostRecent().args[0];
|
|
99
|
+
// Compaction takes slice(-1) so only one message is sent to the LLM
|
|
100
|
+
expect(event.messages.length).toBe(1);
|
|
101
|
+
expect(event.step).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("fires onStep per step with assistantText, per-step toolCalls, usage", async () => {
|
|
105
|
+
const onStep = jasmine.createSpy("onStep");
|
|
106
|
+
|
|
107
|
+
const toolProps: FlinkToolProps = {
|
|
108
|
+
id: "t",
|
|
109
|
+
description: "test",
|
|
110
|
+
inputSchema: z.object({}),
|
|
111
|
+
};
|
|
112
|
+
const toolFn = jasmine.createSpy("toolFn").and.returnValue(Promise.resolve({ success: true, data: { ok: true } }));
|
|
113
|
+
const toolExecutor = new ToolExecutor(toolProps, toolFn as any, mockCtx);
|
|
114
|
+
|
|
115
|
+
const adapter = createStreamingMock([
|
|
116
|
+
{
|
|
117
|
+
textContent: "thinking",
|
|
118
|
+
toolCalls: [{ id: "a", name: "t", input: {} }],
|
|
119
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
120
|
+
stopReason: "tool_use",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
textContent: "done",
|
|
124
|
+
toolCalls: [],
|
|
125
|
+
usage: { inputTokens: 2, outputTokens: 3 },
|
|
126
|
+
stopReason: "end_turn",
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const agent = makeAgent({
|
|
131
|
+
adapter,
|
|
132
|
+
observer: { onStep },
|
|
133
|
+
tools: { t: toolExecutor },
|
|
134
|
+
declaredToolNames: ["t"],
|
|
135
|
+
ctx: mockCtx,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await (agent as any).query({ message: "go" });
|
|
139
|
+
|
|
140
|
+
expect(onStep).toHaveBeenCalledTimes(2);
|
|
141
|
+
const step1 = onStep.calls.all()[0].args[0];
|
|
142
|
+
const step2 = onStep.calls.all()[1].args[0];
|
|
143
|
+
|
|
144
|
+
expect(step1.step).toBe(1);
|
|
145
|
+
expect(step1.assistantText).toBe("thinking");
|
|
146
|
+
expect(step1.toolCalls.length).toBe(1);
|
|
147
|
+
expect(step1.toolCalls[0].name).toBe("t");
|
|
148
|
+
expect(step1.usage).toEqual(jasmine.objectContaining({ inputTokens: 10, outputTokens: 5 }));
|
|
149
|
+
|
|
150
|
+
expect(step2.step).toBe(2);
|
|
151
|
+
expect(step2.assistantText).toBe("done");
|
|
152
|
+
// step2 has no new tool calls
|
|
153
|
+
expect(step2.toolCalls.length).toBe(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("fires onFinish with result.runId matching earlier events' runId", async () => {
|
|
157
|
+
const events: any[] = [];
|
|
158
|
+
const adapter = createStreamingMock([
|
|
159
|
+
{ textContent: "ok", toolCalls: [], usage: { inputTokens: 1, outputTokens: 2 }, stopReason: "end_turn" },
|
|
160
|
+
]);
|
|
161
|
+
const observer: AgentObserver = {
|
|
162
|
+
onRun: (e) => { events.push({ kind: "run", runId: e.runId }); },
|
|
163
|
+
onLlmCall: (e) => { events.push({ kind: "llm", runId: e.runId }); },
|
|
164
|
+
onStep: (e) => { events.push({ kind: "step", runId: e.runId }); },
|
|
165
|
+
onFinish: (e) => { events.push({ kind: "finish", runId: e.runId, resultRunId: e.result.runId }); },
|
|
166
|
+
};
|
|
167
|
+
const agent = makeAgent({ adapter, observer, ctx: mockCtx });
|
|
168
|
+
|
|
169
|
+
const result = await (agent as any).query({ message: "hi" });
|
|
170
|
+
|
|
171
|
+
const runIds = new Set(events.map((e) => e.runId));
|
|
172
|
+
expect(runIds.size).toBe(1);
|
|
173
|
+
const runId = events[0].runId;
|
|
174
|
+
expect(result.runId).toBe(runId);
|
|
175
|
+
const finish = events.find((e) => e.kind === "finish");
|
|
176
|
+
expect(finish.resultRunId).toBe(runId);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("fires onFinish with error populated when adapter throws", async () => {
|
|
180
|
+
const onFinish = jasmine.createSpy("onFinish");
|
|
181
|
+
const adapter: LLMAdapter = {
|
|
182
|
+
stream: jasmine.createSpy("stream").and.callFake(async function* () {
|
|
183
|
+
throw new Error("adapter boom");
|
|
184
|
+
yield {} as LLMStreamChunk; // unreachable, makes TS happy
|
|
185
|
+
}),
|
|
186
|
+
};
|
|
187
|
+
const agent = makeAgent({ adapter, observer: { onFinish }, ctx: mockCtx });
|
|
188
|
+
|
|
189
|
+
await expectAsync((agent as any).query({ message: "hi" })).toBeRejectedWithError(/adapter boom/);
|
|
190
|
+
|
|
191
|
+
expect(onFinish).toHaveBeenCalledTimes(1);
|
|
192
|
+
const event = onFinish.calls.mostRecent().args[0];
|
|
193
|
+
expect(event.error).toMatch(/adapter boom/);
|
|
194
|
+
expect(typeof event.runId).toBe("string");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("swallowed observer errors do not break execution", async () => {
|
|
198
|
+
const adapter = createStreamingMock([
|
|
199
|
+
{ textContent: "ok", toolCalls: [], usage: { inputTokens: 1, outputTokens: 2 }, stopReason: "end_turn" },
|
|
200
|
+
]);
|
|
201
|
+
const observer: AgentObserver = {
|
|
202
|
+
onRun: () => { throw new Error("sync boom"); },
|
|
203
|
+
onLlmCall: async () => { throw new Error("async boom"); },
|
|
204
|
+
onStep: () => { throw new Error("step boom"); },
|
|
205
|
+
onFinish: () => { throw new Error("finish boom"); },
|
|
206
|
+
};
|
|
207
|
+
const agent = makeAgent({ adapter, observer, ctx: mockCtx });
|
|
208
|
+
|
|
209
|
+
const result = await (agent as any).query({ message: "hi" });
|
|
210
|
+
expect(result.message).toBe("ok");
|
|
211
|
+
expect(result.runId).toBeDefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("AgentExecuteResult.runId is populated on success", async () => {
|
|
215
|
+
const adapter = createStreamingMock([
|
|
216
|
+
{ textContent: "ok", toolCalls: [], usage: { inputTokens: 1, outputTokens: 2 }, stopReason: "end_turn" },
|
|
217
|
+
]);
|
|
218
|
+
const agent = makeAgent({ adapter, ctx: mockCtx });
|
|
219
|
+
|
|
220
|
+
const result = await (agent as any).query({ message: "hi" });
|
|
221
|
+
expect(typeof result.runId).toBe("string");
|
|
222
|
+
expect(result.runId.length).toBeGreaterThan(0);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("onLlmCall.tools reflects permission filtering", async () => {
|
|
226
|
+
const onLlmCall = jasmine.createSpy("onLlmCall");
|
|
227
|
+
|
|
228
|
+
const allowedProps: FlinkToolProps = {
|
|
229
|
+
id: "allowed",
|
|
230
|
+
description: "no perms required",
|
|
231
|
+
inputSchema: z.object({}),
|
|
232
|
+
};
|
|
233
|
+
const deniedProps: FlinkToolProps = {
|
|
234
|
+
id: "denied",
|
|
235
|
+
description: "requires admin",
|
|
236
|
+
inputSchema: z.object({}),
|
|
237
|
+
permissions: ["admin"],
|
|
238
|
+
};
|
|
239
|
+
const allowedFn = jasmine.createSpy("allowedFn").and.returnValue(Promise.resolve({ success: true, data: {} }));
|
|
240
|
+
const deniedFn = jasmine.createSpy("deniedFn").and.returnValue(Promise.resolve({ success: true, data: {} }));
|
|
241
|
+
|
|
242
|
+
const allowed = new ToolExecutor(allowedProps, allowedFn as any, mockCtx);
|
|
243
|
+
const denied = new ToolExecutor(deniedProps, deniedFn as any, mockCtx);
|
|
244
|
+
|
|
245
|
+
const adapter = createStreamingMock([
|
|
246
|
+
{ textContent: "ok", toolCalls: [], usage: { inputTokens: 1, outputTokens: 2 }, stopReason: "end_turn" },
|
|
247
|
+
]);
|
|
248
|
+
const agent = makeAgent({
|
|
249
|
+
adapter,
|
|
250
|
+
observer: { onLlmCall },
|
|
251
|
+
tools: { allowed, denied },
|
|
252
|
+
declaredToolNames: ["allowed", "denied"],
|
|
253
|
+
ctx: mockCtx,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await (agent as any).query({
|
|
257
|
+
message: "hi",
|
|
258
|
+
user: { id: "u1" },
|
|
259
|
+
userPermissions: [], // no admin perm → denied tool filtered out
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const event = onLlmCall.calls.mostRecent().args[0];
|
|
263
|
+
expect(event.tools).toContain("allowed");
|
|
264
|
+
expect(event.tools).not.toContain("denied");
|
|
265
|
+
});
|
|
266
|
+
});
|
package/src/FlinkApp.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { AsyncTask, CronJob, SimpleIntervalJob, ToadScheduler } from "toad-sched
|
|
|
11
11
|
import { v4 } from "uuid";
|
|
12
12
|
import { FlinkAgentFile } from "./ai/FlinkAgent";
|
|
13
13
|
import { FlinkToolFile } from "./ai/FlinkTool";
|
|
14
|
+
import { AgentObserver } from "./ai/FlinkAgent";
|
|
14
15
|
import { LLMAdapter } from "./ai/LLMAdapter";
|
|
15
16
|
import { ToolExecutor } from "./ai/ToolExecutor";
|
|
16
17
|
import { FlinkAuthPlugin } from "./auth/FlinkAuthPlugin";
|
|
@@ -227,6 +228,22 @@ export interface FlinkOptions {
|
|
|
227
228
|
*/
|
|
228
229
|
ai?: {
|
|
229
230
|
llms?: { [id: string]: LLMAdapter };
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Global agent observer for app-level tracing, APM, cost accounting, dev tools, etc.
|
|
234
|
+
*
|
|
235
|
+
* Fires for every agent execution in the app. Observer callbacks are invoked
|
|
236
|
+
* fire-and-forget — they may return a Promise but the framework does not await
|
|
237
|
+
* them, and any thrown/rejected errors are caught and logged without affecting
|
|
238
|
+
* agent execution.
|
|
239
|
+
*
|
|
240
|
+
* Events: `onRun` (pre-loop), `onLlmCall` (per step, pre-adapter call),
|
|
241
|
+
* `onStep` (per step end), `onFinish` (post-loop, including error path).
|
|
242
|
+
*
|
|
243
|
+
* For agent-local business logic (conversation persistence, guardrails) use the
|
|
244
|
+
* per-agent `beforeRun` / `onStep` / `afterRun` hooks on `FlinkAgent` instead.
|
|
245
|
+
*/
|
|
246
|
+
observer?: AgentObserver;
|
|
230
247
|
};
|
|
231
248
|
|
|
232
249
|
/**
|
|
@@ -351,6 +368,7 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
351
368
|
private services: { [x: string]: FlinkService<C> } = {};
|
|
352
369
|
|
|
353
370
|
private llmAdapters: Map<string, LLMAdapter> = new Map();
|
|
371
|
+
private agentObserver?: AgentObserver;
|
|
354
372
|
private tools: { [x: string]: ToolExecutor<C> } = {};
|
|
355
373
|
private agents: { [x: string]: any } = {}; // FlinkAgent<C> instances
|
|
356
374
|
|
|
@@ -395,6 +413,9 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
395
413
|
// Convert plain object to Map for internal use
|
|
396
414
|
this.llmAdapters = new Map(Object.entries(opts.ai.llms));
|
|
397
415
|
}
|
|
416
|
+
|
|
417
|
+
// Register global agent observer if configured
|
|
418
|
+
this.agentObserver = opts.ai?.observer;
|
|
398
419
|
}
|
|
399
420
|
|
|
400
421
|
get ctx() {
|
|
@@ -1502,7 +1523,7 @@ export class FlinkApp<C extends FlinkContext> {
|
|
|
1502
1523
|
// Inject context and initialize agents
|
|
1503
1524
|
for (const agent of Object.values(this.agents)) {
|
|
1504
1525
|
agent.ctx = this.ctx;
|
|
1505
|
-
agent.__init(this.llmAdapters, this.tools);
|
|
1526
|
+
agent.__init(this.llmAdapters, this.tools, this.agentObserver);
|
|
1506
1527
|
}
|
|
1507
1528
|
}
|
|
1508
1529
|
|