@flink-app/flink 2.0.0-alpha.90 → 2.0.0-alpha.92

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.
@@ -1,3 +1,4 @@
1
+ import { v4 as uuidv4 } from "uuid";
1
2
  import {
2
3
  FlinkAgentProps,
3
4
  AgentExecuteResult,
@@ -7,11 +8,15 @@ import {
7
8
  AgentExecuteContext,
8
9
  AgentStepContext,
9
10
  AgentFinishContext,
11
+ AgentObserver,
10
12
  } from "./FlinkAgent";
11
13
  import { ToolExecutor } from "./ToolExecutor";
12
14
  import { LLMAdapter, LLMMessage, LLMContentBlock, FlinkToolSchema } from "./LLMAdapter";
15
+ import { FlinkLogFactory } from "../FlinkLogFactory";
13
16
  import { log } from "../FlinkLog";
14
17
 
18
+ const observerLog = FlinkLogFactory.createLogger("flink.ai.observer");
19
+
15
20
  export class AgentRunner {
16
21
  private llmAdapter: LLMAdapter;
17
22
  private maxTokens: number;
@@ -24,7 +29,8 @@ export class AgentRunner {
24
29
  private tools: Map<string, ToolExecutor<any>>,
25
30
  llmAdapters: Map<string, LLMAdapter>,
26
31
  private agentName?: string, // Optional agent name for logging
27
- private ctx?: any // FlinkContext for instruction callbacks (any for flexibility)
32
+ private ctx?: any, // FlinkContext for instruction callbacks (any for flexibility)
33
+ private observer?: AgentObserver
28
34
  ) {
29
35
  // Get appropriate LLM adapter based on adapterId
30
36
  const adapterId = agentProps.model?.adapterId || "default";
@@ -44,10 +50,19 @@ export class AgentRunner {
44
50
  async *streamGenerator(input: AgentExecuteInput): AsyncGenerator<StreamChunk> {
45
51
  const maxSteps = input.options?.maxSteps || this.maxSteps;
46
52
  const toolCalls: AgentExecuteResult["toolCalls"] = [];
53
+ const runId = uuidv4();
54
+ const runStartedAt = Date.now();
55
+ const agentId = this.agentName || "unknown";
56
+ const modelInfo = {
57
+ adapterId: this.agentProps.model?.adapterId,
58
+ maxTokens: this.maxTokens,
59
+ temperature: this.temperature,
60
+ };
61
+ const declaredToolNames = Array.from(this.tools.keys());
47
62
 
48
63
  // Build execution context
49
64
  const execContext: AgentExecuteContext = {
50
- agentId: this.agentName || "unknown",
65
+ agentId,
51
66
  conversationId: input.conversationId,
52
67
  user: input.user,
53
68
  metadata: input.metadata,
@@ -83,6 +98,20 @@ export class AgentRunner {
83
98
  messages.push(...this.convertMessages(input.message as Message[]));
84
99
  }
85
100
 
101
+ // Dispatch observer onRun (pre-loop, before compaction / tool filtering)
102
+ this.safeDispatch("onRun", () =>
103
+ this.observer?.onRun?.({
104
+ runId,
105
+ agentId,
106
+ instructions: resolvedInstructions,
107
+ input,
108
+ messages: [...messages],
109
+ tools: declaredToolNames,
110
+ model: modelInfo,
111
+ context: execContext,
112
+ })
113
+ );
114
+
86
115
  let step = 0;
87
116
  let finalMessage = "";
88
117
  let stoppedEarly = false;
@@ -92,6 +121,22 @@ export class AgentRunner {
92
121
  let totalCacheCreationInputTokens = 0;
93
122
  let finalProviderMetadata: Record<string, any> = {};
94
123
 
124
+ const buildResult = (): AgentExecuteResult => ({
125
+ runId,
126
+ message: finalMessage,
127
+ toolCalls,
128
+ stepsUsed: step,
129
+ stoppedEarly,
130
+ usage: {
131
+ inputTokens: totalInputTokens,
132
+ outputTokens: totalOutputTokens,
133
+ ...(totalCachedInputTokens > 0 && { cachedInputTokens: totalCachedInputTokens }),
134
+ ...(totalCacheCreationInputTokens > 0 && { cacheCreationInputTokens: totalCacheCreationInputTokens }),
135
+ },
136
+ providerMetadata: Object.keys(finalProviderMetadata).length > 0 ? finalProviderMetadata : undefined,
137
+ });
138
+
139
+ try {
95
140
  while (step < maxSteps) {
96
141
  step++;
97
142
 
@@ -160,6 +205,26 @@ export class AgentRunner {
160
205
  temperature: this.temperature,
161
206
  });
162
207
 
208
+ // Dispatch observer onLlmCall — messages reflect post-compaction state;
209
+ // tools reflect per-step permission filtering
210
+ this.safeDispatch("onLlmCall", () =>
211
+ this.observer?.onLlmCall?.({
212
+ runId,
213
+ agentId,
214
+ step,
215
+ maxSteps,
216
+ instructions: resolvedInstructions,
217
+ messages: [...messages],
218
+ tools: availableTools.map((t) => t.name),
219
+ model: modelInfo,
220
+ context: execContext,
221
+ })
222
+ );
223
+
224
+ // Track tool call count before this step so we can slice the per-step
225
+ // tool calls for the onStep observer event
226
+ const toolCallsBeforeStep = toolCalls.length;
227
+
163
228
  // Call AI model via adapter using streaming
164
229
  const llmStream = this.llmAdapter.stream({
165
230
  instructions: resolvedInstructions,
@@ -272,6 +337,20 @@ export class AgentRunner {
272
337
  };
273
338
  await this.agentProps.onStep(stepContext);
274
339
  }
340
+ // Dispatch observer onStep (no tool calls executed this step)
341
+ this.safeDispatch("onStep", () =>
342
+ this.observer?.onStep?.({
343
+ runId,
344
+ agentId,
345
+ step,
346
+ maxSteps,
347
+ messages: [...messages],
348
+ assistantText: llmResponse.textContent,
349
+ toolCalls: toolCalls.slice(toolCallsBeforeStep),
350
+ usage,
351
+ context: execContext,
352
+ })
353
+ );
275
354
  break; // No more tool calls - done
276
355
  }
277
356
 
@@ -400,6 +479,21 @@ export class AgentRunner {
400
479
  };
401
480
  await this.agentProps.onStep(stepContext);
402
481
  }
482
+
483
+ // Dispatch observer onStep with per-step tool calls slice
484
+ this.safeDispatch("onStep", () =>
485
+ this.observer?.onStep?.({
486
+ runId,
487
+ agentId,
488
+ step,
489
+ maxSteps,
490
+ messages: [...messages],
491
+ assistantText: llmResponse.textContent,
492
+ toolCalls: toolCalls.slice(toolCallsBeforeStep),
493
+ usage,
494
+ context: execContext,
495
+ })
496
+ );
403
497
  }
404
498
 
405
499
  if (step >= maxSteps && toolCalls.length > 0) {
@@ -407,19 +501,7 @@ export class AgentRunner {
407
501
  log.warn(`Agent ${this.agentName || "unknown"} stopped early after ${maxSteps} steps`);
408
502
  }
409
503
 
410
- const result: AgentExecuteResult = {
411
- message: finalMessage,
412
- toolCalls,
413
- stepsUsed: step,
414
- stoppedEarly,
415
- usage: {
416
- inputTokens: totalInputTokens,
417
- outputTokens: totalOutputTokens,
418
- ...(totalCachedInputTokens > 0 && { cachedInputTokens: totalCachedInputTokens }),
419
- ...(totalCacheCreationInputTokens > 0 && { cacheCreationInputTokens: totalCacheCreationInputTokens }),
420
- },
421
- providerMetadata: Object.keys(finalProviderMetadata).length > 0 ? finalProviderMetadata : undefined,
422
- };
504
+ const result = buildResult();
423
505
 
424
506
  // Call afterRun hook with full context
425
507
  if (this.agentProps.afterRun) {
@@ -431,11 +513,55 @@ export class AgentRunner {
431
513
  await this.agentProps.afterRun(result, finishContext);
432
514
  }
433
515
 
516
+ // Dispatch observer onFinish (success path)
517
+ this.safeDispatch("onFinish", () =>
518
+ this.observer?.onFinish?.({
519
+ runId,
520
+ agentId,
521
+ result,
522
+ messages: [...messages],
523
+ durationMs: Date.now() - runStartedAt,
524
+ context: execContext,
525
+ })
526
+ );
527
+
434
528
  // Phase 1: Yield only complete event
435
529
  // Phase 2: Will yield text_delta and tool events during loop
436
530
  yield { type: "complete", result };
437
531
 
438
532
  return result;
533
+ } catch (err: any) {
534
+ // Dispatch observer onFinish with error before rethrowing
535
+ this.safeDispatch("onFinish", () =>
536
+ this.observer?.onFinish?.({
537
+ runId,
538
+ agentId,
539
+ result: buildResult(),
540
+ messages: [...messages],
541
+ durationMs: Date.now() - runStartedAt,
542
+ error: err?.message || String(err),
543
+ context: execContext,
544
+ })
545
+ );
546
+ throw err;
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Fire-and-forget observer dispatch: catches synchronous throws and
552
+ * rejected promises so observer failures never break agent execution.
553
+ */
554
+ private safeDispatch(eventName: string, invoke: () => void | Promise<void> | undefined): void {
555
+ try {
556
+ const maybePromise = invoke();
557
+ if (maybePromise && typeof (maybePromise as Promise<void>).then === "function") {
558
+ (maybePromise as Promise<void>).catch((err: any) => {
559
+ observerLog.warn(`AgentObserver.${eventName} threw (async):`, err?.message || err);
560
+ });
561
+ }
562
+ } catch (err: any) {
563
+ observerLog.warn(`AgentObserver.${eventName} threw (sync):`, err?.message || err);
564
+ }
439
565
  }
440
566
 
441
567
  /**
@@ -250,6 +250,7 @@ export abstract class FlinkAgent<Ctx extends FlinkContext, ConversationCtx = any
250
250
  private _boundConversationContext?: ConversationCtx; // Conversation context bound via withConversationContext()
251
251
  private _llmAdapters?: Map<string, any>;
252
252
  private _tools?: { [x: string]: ToolExecutor<Ctx> };
253
+ private _observer?: AgentObserver;
253
254
 
254
255
  // Abstract properties (must be defined by subclass)
255
256
  abstract id: string;
@@ -315,9 +316,10 @@ export abstract class FlinkAgent<Ctx extends FlinkContext, ConversationCtx = any
315
316
  * Internal initialization called by FlinkApp
316
317
  * @internal
317
318
  */
318
- __init(llmAdapters: Map<string, any>, tools: { [x: string]: ToolExecutor<Ctx> }): void {
319
+ __init(llmAdapters: Map<string, any>, tools: { [x: string]: ToolExecutor<Ctx> }, observer?: AgentObserver): void {
319
320
  this._llmAdapters = llmAdapters;
320
321
  this._tools = tools;
322
+ this._observer = observer;
321
323
  }
322
324
 
323
325
  /**
@@ -350,6 +352,9 @@ export abstract class FlinkAgent<Ctx extends FlinkContext, ConversationCtx = any
350
352
  if (this._tools) {
351
353
  bound._tools = this._tools;
352
354
  }
355
+ if (this._observer) {
356
+ bound._observer = this._observer;
357
+ }
353
358
  if (this._boundUserPermissions !== undefined) {
354
359
  bound._boundUserPermissions = this._boundUserPermissions;
355
360
  }
@@ -389,6 +394,9 @@ export abstract class FlinkAgent<Ctx extends FlinkContext, ConversationCtx = any
389
394
  if (this._tools) {
390
395
  bound._tools = this._tools;
391
396
  }
397
+ if (this._observer) {
398
+ bound._observer = this._observer;
399
+ }
392
400
  if (this._boundUser !== undefined) {
393
401
  bound._boundUser = this._boundUser;
394
402
  }
@@ -430,6 +438,9 @@ export abstract class FlinkAgent<Ctx extends FlinkContext, ConversationCtx = any
430
438
  if (this._tools) {
431
439
  bound._tools = this._tools;
432
440
  }
441
+ if (this._observer) {
442
+ bound._observer = this._observer;
443
+ }
433
444
  if (this._boundUser !== undefined) {
434
445
  bound._boundUser = this._boundUser;
435
446
  }
@@ -471,6 +482,9 @@ export abstract class FlinkAgent<Ctx extends FlinkContext, ConversationCtx = any
471
482
  if (this._tools) {
472
483
  bound._tools = this._tools;
473
484
  }
485
+ if (this._observer) {
486
+ bound._observer = this._observer;
487
+ }
474
488
  if (this._boundUser !== undefined) {
475
489
  bound._boundUser = this._boundUser;
476
490
  }
@@ -712,7 +726,8 @@ export abstract class FlinkAgent<Ctx extends FlinkContext, ConversationCtx = any
712
726
  toolsMap,
713
727
  llmAdapters,
714
728
  this.getAgentId(),
715
- this.ctx // Pass ctx to runner so callbacks can access it
729
+ this.ctx, // Pass ctx to runner so callbacks can access it
730
+ this._observer
716
731
  );
717
732
  }
718
733
  return this.runner;
@@ -909,6 +924,12 @@ export interface AgentExecuteInput<ConversationCtx = any> {
909
924
  }
910
925
 
911
926
  export interface AgentExecuteResult {
927
+ /**
928
+ * Framework-generated unique ID for this agent execution.
929
+ * Matches the `runId` emitted in AgentObserver events, enabling apps to
930
+ * correlate persisted results with observer traces.
931
+ */
932
+ runId: string;
912
933
  message: string; // Final AI response
913
934
  toolCalls: Array<{
914
935
  name: string;
@@ -957,3 +978,92 @@ export interface AgentResponse {
957
978
  textStream: AsyncGenerator<string>; // Stream only text deltas
958
979
  fullStream: AsyncGenerator<StreamChunk>; // Stream all events
959
980
  }
981
+
982
+ /**
983
+ * Event fired once per agent execution, before the first LLM call.
984
+ * Contains the resolved system instructions and the initial message array
985
+ * (post-history, pre-compaction, pre-tool-filtering).
986
+ */
987
+ export interface AgentObserverRunEvent {
988
+ runId: string;
989
+ agentId: string;
990
+ instructions: string;
991
+ input: AgentExecuteInput;
992
+ messages: LLMMessage[];
993
+ tools: string[];
994
+ model: { adapterId?: string; maxTokens?: number; temperature?: number };
995
+ context: AgentExecuteContext;
996
+ }
997
+
998
+ /**
999
+ * Event fired immediately before each LLM call in the agentic loop.
1000
+ * Reflects the messages and tools actually sent to the model after
1001
+ * compaction and per-step permission filtering.
1002
+ */
1003
+ export interface AgentObserverLlmCallEvent {
1004
+ runId: string;
1005
+ agentId: string;
1006
+ step: number;
1007
+ maxSteps: number;
1008
+ instructions: string;
1009
+ messages: LLMMessage[];
1010
+ tools: string[];
1011
+ model: { adapterId?: string; maxTokens?: number; temperature?: number };
1012
+ context: AgentExecuteContext;
1013
+ }
1014
+
1015
+ /**
1016
+ * Event fired after each step (LLM call + tool executions) completes.
1017
+ * `toolCalls` contains only the tool calls executed during this step.
1018
+ */
1019
+ export interface AgentObserverStepEvent {
1020
+ runId: string;
1021
+ agentId: string;
1022
+ step: number;
1023
+ maxSteps: number;
1024
+ messages: LLMMessage[];
1025
+ assistantText?: string;
1026
+ toolCalls: AgentExecuteResult["toolCalls"];
1027
+ usage?: import("./LLMAdapter").LLMUsage;
1028
+ context: AgentExecuteContext;
1029
+ }
1030
+
1031
+ /**
1032
+ * Event fired when an agent execution completes (successfully or with error).
1033
+ * On error, `result` contains whatever was accumulated before the throw.
1034
+ */
1035
+ export interface AgentObserverFinishEvent {
1036
+ runId: string;
1037
+ agentId: string;
1038
+ result: AgentExecuteResult;
1039
+ messages: LLMMessage[];
1040
+ durationMs: number;
1041
+ error?: string;
1042
+ context: AgentExecuteContext;
1043
+ }
1044
+
1045
+ /**
1046
+ * Global agent observer for app-level tracing, APM, cost accounting, dev tools, etc.
1047
+ *
1048
+ * Register once on `FlinkOptions.ai.observer`; fires for every agent execution in the app.
1049
+ *
1050
+ * Observer callbacks are invoked fire-and-forget: they may return a Promise but the
1051
+ * framework does not await them, and any thrown/rejected errors are caught and logged
1052
+ * without affecting agent execution. Observers are read-only — they must not mutate
1053
+ * inputs, messages, or results. Use the per-agent `beforeRun`/`onStep`/`afterRun` hooks
1054
+ * for business logic that needs to block or mutate.
1055
+ *
1056
+ * Typical use cases:
1057
+ * - Persisted traces for a dev tools page (correlate via `context.metadata`)
1058
+ * - OpenTelemetry / Sentry integration
1059
+ * - Cost accounting and token-usage dashboards
1060
+ *
1061
+ * All events share a stable `runId` per execution so persisted records can be joined
1062
+ * across events. The same `runId` is returned on `AgentExecuteResult.runId`.
1063
+ */
1064
+ export interface AgentObserver {
1065
+ onRun?(event: AgentObserverRunEvent): void | Promise<void>;
1066
+ onLlmCall?(event: AgentObserverLlmCallEvent): void | Promise<void>;
1067
+ onStep?(event: AgentObserverStepEvent): void | Promise<void>;
1068
+ onFinish?(event: AgentObserverFinishEvent): void | Promise<void>;
1069
+ }