graphlit-client 1.0.20260602001 → 1.0.20260605001

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/dist/client.d.ts CHANGED
@@ -3250,6 +3250,17 @@ declare class Graphlit {
3250
3250
  * with `intermediateMessages` rather than a single tool-call list.
3251
3251
  */
3252
3252
  private detectTaskCompleteInMessages;
3253
+ /**
3254
+ * Treat a non-empty text-only turn as terminal when the caller explicitly
3255
+ * opts into interactive completion semantics.
3256
+ */
3257
+ private detectTerminalTextCompletion;
3258
+ /**
3259
+ * Reconstruct terminal-text completion across resumed conversations. A
3260
+ * text-only assistant turn is considered terminal if it was not followed by
3261
+ * the harness' synthetic bare continuation prompt.
3262
+ */
3263
+ private detectPersistedTurnCompletionReason;
3253
3264
  /**
3254
3265
  * Run LLM-as-judge quality assessment on a completed agent run.
3255
3266
  */
package/dist/client.js CHANGED
@@ -11,7 +11,7 @@ import { getServiceType, getModelName, getModelEnum, isAnthropicAdaptiveThinking
11
11
  import { StuckDetector } from "./helpers/stuck-detector.js";
12
12
  import { TurnEvaluator } from "./helpers/turn-evaluator.js";
13
13
  import { TokenBudgetTracker, truncateToolResult, windowToolRounds, estimateTokens, DEFAULT_CONTEXT_STRATEGY, } from "./helpers/context-management.js";
14
- import { ProviderError } from "./types/internal.js";
14
+ import { isRetryableGraphQLTransportError, ProviderError, } from "./types/internal.js";
15
15
  import { UIEventAdapter } from "./streaming/ui-event-adapter.js";
16
16
  import { formatMessagesForOpenAI, formatMessagesForOpenAIResponsesInitialRound, extractInstructionsForOpenAIResponses, extractSystemInstructionParts, buildResponsesFunctionCallOutputItems, formatToolsForOpenAIResponses, formatMessagesForAnthropic, formatMessagesForGoogle, formatMessagesForMistral, formatMessagesForBedrock, } from "./streaming/llm-formatters.js";
17
17
  import { streamWithOpenAIResponses, } from "./streaming/openai-responses.js";
@@ -528,7 +528,9 @@ class Graphlit {
528
528
  }
529
529
  refreshClient() {
530
530
  this.client = undefined;
531
- this.generateToken();
531
+ if (this.jwtSecret) {
532
+ this.generateToken();
533
+ }
532
534
  this.setupApolloClient();
533
535
  }
534
536
  setupApolloClient() {
@@ -548,28 +550,15 @@ class Graphlit {
548
550
  // Check if we should retry this error
549
551
  if (!error)
550
552
  return false;
551
- // Check for network errors
552
- const hasNetworkError = !!error.networkError;
553
- if (!hasNetworkError)
554
- return false;
555
- // Get status code from different possible locations
556
- const statusCode = error.networkError?.statusCode ||
557
- error.networkError?.response?.status ||
558
- error.statusCode;
559
- // Check if status code is retryable
560
- if (statusCode && this.retryConfig.retryableStatusCodes) {
561
- const shouldRetry = this.retryConfig.retryableStatusCodes.includes(statusCode);
562
- // Call onRetry callback if provided
563
- if (shouldRetry &&
564
- this.retryConfig.onRetry &&
565
- _operation.getContext().retryCount !== undefined) {
566
- const attempt = _operation.getContext().retryCount + 1;
567
- this.retryConfig.onRetry(attempt, error, _operation);
568
- }
569
- return shouldRetry;
553
+ const shouldRetry = isRetryableGraphQLTransportError(error, this.retryConfig.retryableStatusCodes);
554
+ // Call onRetry callback if provided
555
+ if (shouldRetry &&
556
+ this.retryConfig.onRetry &&
557
+ _operation.getContext().retryCount !== undefined) {
558
+ const attempt = _operation.getContext().retryCount + 1;
559
+ this.retryConfig.onRetry(attempt, error, _operation);
570
560
  }
571
- // Default: retry on network errors without specific status codes
572
- return true;
561
+ return shouldRetry;
573
562
  },
574
563
  },
575
564
  });
@@ -7233,12 +7222,14 @@ class Graphlit {
7233
7222
  const turnResults = [];
7234
7223
  let totalToolCalls = 0;
7235
7224
  let status = "completed";
7225
+ let completionReason;
7236
7226
  let finalMessage = "";
7237
7227
  let stuckPattern;
7238
7228
  let errorMessage;
7239
7229
  let lastContextWindow;
7240
7230
  let conversationId = options?.conversationId ?? "";
7241
7231
  let resolvedAgentId = agentId ?? "";
7232
+ let resumedTurnCount = 0;
7242
7233
  // Accumulate token usage across all turns from conversation_completed events
7243
7234
  let accumulatedUsage = {
7244
7235
  promptTokens: 0,
@@ -7424,6 +7415,7 @@ class Graphlit {
7424
7415
  }
7425
7416
  }
7426
7417
  }
7418
+ resumedTurnCount = turnResults.length;
7427
7419
  const fallbackSpecs = await this.resolveSpecificationReferences(fallbackReferences.filter((fallback) => fallback.id !== fullSpec?.id));
7428
7420
  // ── Phase 2: The Loop ──────────────────────────────────────────────
7429
7421
  let pendingStuckIntervention;
@@ -7643,6 +7635,7 @@ class Graphlit {
7643
7635
  // records the terminal assistant/tool-call round, but does not execute
7644
7636
  // the completion handler or start a follow-up filler round.
7645
7637
  const taskCompleteThisTurn = this.detectTaskCompleteInMessages(loopResult.intermediateMessages);
7638
+ const terminalTextThisTurn = this.detectTerminalTextCompletion(loopResult, options?.completionMode);
7646
7639
  const trimmedMessage = loopResult.finalAssistantMessage;
7647
7640
  // 8b. Complete conversation (persist turn)
7648
7641
  if (trimmedMessage) {
@@ -7701,6 +7694,11 @@ class Graphlit {
7701
7694
  toolCallCount: loopResult.toolCallCount,
7702
7695
  durationMs: turnDuration,
7703
7696
  taskComplete: taskCompleteThisTurn,
7697
+ completionReason: taskCompleteThisTurn
7698
+ ? "task_complete"
7699
+ : terminalTextThisTurn
7700
+ ? "terminal_text"
7701
+ : undefined,
7704
7702
  contextWindowUsage: loopResult.contextWindow,
7705
7703
  contextActions: loopResult.contextActions.length > 0
7706
7704
  ? loopResult.contextActions
@@ -7718,9 +7716,10 @@ class Graphlit {
7718
7716
  // 10. Notify callback
7719
7717
  options?.onTurnComplete?.(turnResult);
7720
7718
  // 11. Evaluate turn
7721
- // a. task_complete called?
7722
- if (taskCompleteThisTurn) {
7719
+ // a. Terminal completion reached?
7720
+ if (turnResult.completionReason) {
7723
7721
  status = "completed";
7722
+ completionReason = turnResult.completionReason;
7724
7723
  break;
7725
7724
  }
7726
7725
  // b/c. Error handling
@@ -7762,9 +7761,9 @@ class Graphlit {
7762
7761
  // status defaults to "completed", so we need a positive signal that
7763
7762
  // task_complete was actually called — otherwise hitting the turn cap
7764
7763
  // looks like success.
7765
- const taskCompleteWasCalled = turnResults.some((t) => t.taskComplete);
7764
+ const runCompletedNaturally = turnResults.slice(resumedTurnCount).some((t) => t.taskComplete || t.completionReason === "terminal_text");
7766
7765
  if (status === "completed" &&
7767
- !taskCompleteWasCalled &&
7766
+ !runCompletedNaturally &&
7768
7767
  turnResults.length >= evaluator.adjustedMaxTurns) {
7769
7768
  status = "budget_exhausted";
7770
7769
  }
@@ -7780,6 +7779,7 @@ class Graphlit {
7780
7779
  conversationId,
7781
7780
  status,
7782
7781
  finalMessage,
7782
+ completionReason,
7783
7783
  turnResults,
7784
7784
  totalToolCalls,
7785
7785
  wallClockMs,
@@ -7805,6 +7805,7 @@ class Graphlit {
7805
7805
  conversationId,
7806
7806
  status,
7807
7807
  finalMessage,
7808
+ completionReason,
7808
7809
  turnResults,
7809
7810
  totalToolCalls,
7810
7811
  wallClockMs: Date.now() - runStart,
@@ -7878,8 +7879,9 @@ class Graphlit {
7878
7879
  toolNames.push(tc.name);
7879
7880
  }
7880
7881
  }
7881
- // Check tool response messages for errors
7882
+ // Check tool response messages for errors and locate the next user turn.
7882
7883
  const toolErrors = [];
7884
+ let nextUserMsg;
7883
7885
  for (let j = i + 1; j < messages.length; j++) {
7884
7886
  const respMsg = messages[j];
7885
7887
  if (respMsg.role === Types.ConversationRoleTypes.Tool) {
@@ -7888,9 +7890,11 @@ class Graphlit {
7888
7890
  }
7889
7891
  }
7890
7892
  else if (respMsg.role === Types.ConversationRoleTypes.User) {
7893
+ nextUserMsg = respMsg;
7891
7894
  break; // Next turn
7892
7895
  }
7893
7896
  }
7897
+ const taskComplete = this.detectTaskComplete(assistantMsg.toolCalls);
7894
7898
  results.push({
7895
7899
  turnNumber: turnNumber++,
7896
7900
  prompt: msg.message || "",
@@ -7898,7 +7902,10 @@ class Graphlit {
7898
7902
  toolCalls: [...new Set(toolNames)].sort(),
7899
7903
  toolCallCount: toolNames.length,
7900
7904
  durationMs: computePersistedToolRoundDurationMs(assistantMsg.toolCalls) ?? 0,
7901
- taskComplete: this.detectTaskComplete(assistantMsg.toolCalls),
7905
+ taskComplete,
7906
+ completionReason: taskComplete
7907
+ ? "task_complete"
7908
+ : this.detectPersistedTurnCompletionReason(assistantMsg, nextUserMsg),
7902
7909
  errors: toolErrors.length > 0 ? toolErrors : undefined,
7903
7910
  });
7904
7911
  }
@@ -8035,6 +8042,40 @@ class Graphlit {
8035
8042
  }
8036
8043
  return false;
8037
8044
  }
8045
+ /**
8046
+ * Treat a non-empty text-only turn as terminal when the caller explicitly
8047
+ * opts into interactive completion semantics.
8048
+ */
8049
+ detectTerminalTextCompletion(loopResult, completionMode) {
8050
+ if (completionMode !== "allow_terminal_text") {
8051
+ return false;
8052
+ }
8053
+ if (loopResult.toolCallCount !== 0) {
8054
+ return false;
8055
+ }
8056
+ if (loopResult.errors.length > 0) {
8057
+ return false;
8058
+ }
8059
+ return loopResult.finalAssistantMessage.trim().length > 0;
8060
+ }
8061
+ /**
8062
+ * Reconstruct terminal-text completion across resumed conversations. A
8063
+ * text-only assistant turn is considered terminal if it was not followed by
8064
+ * the harness' synthetic bare continuation prompt.
8065
+ */
8066
+ detectPersistedTurnCompletionReason(assistantMessage, nextMessage) {
8067
+ if ((assistantMessage.message || "").trim().length === 0) {
8068
+ return undefined;
8069
+ }
8070
+ if ((assistantMessage.toolCalls?.length ?? 0) > 0) {
8071
+ return undefined;
8072
+ }
8073
+ if (nextMessage?.role === Types.ConversationRoleTypes.User &&
8074
+ (nextMessage.message || "").trim() === "Continue.") {
8075
+ return undefined;
8076
+ }
8077
+ return "terminal_text";
8078
+ }
8038
8079
  /**
8039
8080
  * Run LLM-as-judge quality assessment on a completed agent run.
8040
8081
  */
@@ -8114,6 +8155,7 @@ class Graphlit {
8114
8155
  conversationId: params.conversationId,
8115
8156
  status: params.status,
8116
8157
  finalMessage: params.finalMessage,
8158
+ completionReason: params.completionReason,
8117
8159
  turns: params.turnResults.length,
8118
8160
  totalToolCalls: params.totalToolCalls,
8119
8161
  wallClockMs: params.wallClockMs,
@@ -41,7 +41,7 @@ const STUCK_WINDOW = 5; // Number of recent turns to examine
41
41
  const REPEAT_THRESHOLD = 3; // Minimum identical occurrences to detect a pattern
42
42
  const SIMILARITY_THRESHOLD = 0.9; // Trigram similarity threshold for "same" response
43
43
  const CONSECUTIVE_ERROR_THRESHOLD = 3; // Consecutive all-error turns
44
- const CONSECUTIVE_EMPTY_THRESHOLD = 2; // Consecutive empty (no tool, no task_complete) turns
44
+ const CONSECUTIVE_EMPTY_THRESHOLD = 2; // Consecutive empty non-terminal turns
45
45
  /**
46
46
  * Stateful detector that tracks patterns across harness turns and identifies
47
47
  * when the agent is stuck in a loop.
@@ -69,7 +69,8 @@ export class StuckDetector {
69
69
  const allErrors = turn.toolCallCount > 0 &&
70
70
  (turn.errors?.length ?? 0) >= turn.toolCallCount;
71
71
  this.errorHistory.push(allErrors);
72
- if (turn.toolCallCount === 0 && !turn.taskComplete) {
72
+ const turnCompleted = turn.taskComplete || turn.completionReason != null;
73
+ if (turn.toolCallCount === 0 && !turnCompleted) {
73
74
  this.emptyTurnCount++;
74
75
  }
75
76
  else {
@@ -113,7 +114,7 @@ export class StuckDetector {
113
114
  return this.strike("error_loop");
114
115
  }
115
116
  }
116
- // 4. Empty turns — 2+ consecutive turns with 0 tool calls and no task_complete
117
+ // 4. Empty turns — 2+ consecutive turns with 0 tool calls and no terminal completion
117
118
  if (this.emptyTurnCount >= CONSECUTIVE_EMPTY_THRESHOLD) {
118
119
  return this.strike("empty_turns");
119
120
  }
@@ -118,6 +118,10 @@ export interface AgentError {
118
118
  }
119
119
  /** Terminal status of a runAgent execution. */
120
120
  export type HarnessStatus = "completed" | "budget_exhausted" | "stuck" | "error" | "cancelled";
121
+ /** How a runAgent execution reached terminal completion. */
122
+ export type RunCompletionReason = "task_complete" | "terminal_text";
123
+ /** Policy controlling when text-only turns should be treated as terminal. */
124
+ export type RunCompletionMode = "task_complete_only" | "allow_terminal_text";
121
125
  /** Per-turn data captured by the harness. */
122
126
  export interface TurnResult {
123
127
  turnNumber: number;
@@ -127,6 +131,7 @@ export interface TurnResult {
127
131
  toolCallCount: number;
128
132
  durationMs: number;
129
133
  taskComplete?: boolean;
134
+ completionReason?: RunCompletionReason;
130
135
  contextWindowUsage?: ContextWindowUsage;
131
136
  contextActions?: ContextManagementAction[];
132
137
  errors?: string[];
@@ -161,6 +166,7 @@ export interface RunAgentOptions {
161
166
  maxWallClockMs?: number;
162
167
  maxToolCalls?: number;
163
168
  windDownTurns?: number;
169
+ completionMode?: RunCompletionMode;
164
170
  onTurnComplete?: (turn: TurnResult) => void;
165
171
  onBudgetWarning?: (snapshot: BudgetSnapshot) => void;
166
172
  onStreamEvent?: (event: AgentStreamEvent) => void;
@@ -193,6 +199,7 @@ export interface RunAgentResult {
193
199
  conversationId: string;
194
200
  status: HarnessStatus;
195
201
  finalMessage: string;
202
+ completionReason?: RunCompletionReason;
196
203
  turns: number;
197
204
  totalToolCalls: number;
198
205
  wallClockMs: number;
@@ -117,6 +117,19 @@ export declare class ProviderError extends Error {
117
117
  * Used as a catch-all after provider-specific error classification.
118
118
  */
119
119
  export declare function isRetryableServerError(error: any): boolean;
120
+ /**
121
+ * Extract an HTTP status code from common Apollo/network error shapes.
122
+ */
123
+ export declare function getErrorStatusCode(error: any): number | undefined;
124
+ /**
125
+ * Detect retryable GraphQL transport errors from Apollo RetryLink.
126
+ *
127
+ * RetryLink can receive either:
128
+ * - an ApolloError with `networkError`
129
+ * - a raw HttpLink `ServerError` with `statusCode` / `response.status`
130
+ * - a nested network/cause error from undici/fetch
131
+ */
132
+ export declare function isRetryableGraphQLTransportError(error: any, retryableStatusCodes?: number[]): boolean;
120
133
  /**
121
134
  * Detect rate-limit / overloaded errors across providers.
122
135
  */
@@ -37,6 +37,49 @@ export function isRetryableServerError(error) {
37
37
  msg.includes("bad gateway") ||
38
38
  msg.includes("gateway timeout"));
39
39
  }
40
+ /**
41
+ * Extract an HTTP status code from common Apollo/network error shapes.
42
+ */
43
+ export function getErrorStatusCode(error) {
44
+ if (!error || typeof error !== "object")
45
+ return undefined;
46
+ const status = typeof error.status === "number"
47
+ ? error.status
48
+ : typeof error.statusCode === "number"
49
+ ? error.statusCode
50
+ : undefined;
51
+ if (typeof status === "number")
52
+ return status;
53
+ const responseStatus = typeof error.response?.status === "number"
54
+ ? error.response.status
55
+ : undefined;
56
+ if (typeof responseStatus === "number")
57
+ return responseStatus;
58
+ return (getErrorStatusCode(error.networkError) ?? getErrorStatusCode(error.cause));
59
+ }
60
+ /**
61
+ * Detect retryable GraphQL transport errors from Apollo RetryLink.
62
+ *
63
+ * RetryLink can receive either:
64
+ * - an ApolloError with `networkError`
65
+ * - a raw HttpLink `ServerError` with `statusCode` / `response.status`
66
+ * - a nested network/cause error from undici/fetch
67
+ */
68
+ export function isRetryableGraphQLTransportError(error, retryableStatusCodes = [429, 500, 502, 503, 504]) {
69
+ if (!error)
70
+ return false;
71
+ const statusCode = getErrorStatusCode(error);
72
+ if (typeof statusCode === "number") {
73
+ return retryableStatusCodes.includes(statusCode);
74
+ }
75
+ if (Array.isArray(error.graphQLErrors) && error.graphQLErrors.length > 0) {
76
+ return false;
77
+ }
78
+ return (!!error.networkError ||
79
+ isNetworkError(error) ||
80
+ isRetryableServerError(error) ||
81
+ isRetryableGraphQLTransportError(error.cause, retryableStatusCodes));
82
+ }
40
83
  /**
41
84
  * Detect rate-limit / overloaded errors across providers.
42
85
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphlit-client",
3
- "version": "1.0.20260602001",
3
+ "version": "1.0.20260605001",
4
4
  "description": "Graphlit API Client for TypeScript",
5
5
  "type": "module",
6
6
  "main": "./dist/client.js",