@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flink-app/flink",
3
- "version": "2.0.0-alpha.91",
3
+ "version": "2.0.0-alpha.93",
4
4
  "description": "Typescript only framework for creating REST-like APIs on top of Express and mongodb",
5
5
  "types": "dist/src/index.d.ts",
6
6
  "main": "dist/src/index.js",
@@ -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