feat(agent): AgentHarness streams through a required Models instance (phase 6)

AgentHarnessOptions.models is required; the harness stream path,
compaction, and branch summarization go through models.streamSimple()/
completeSimple() instead of the compat globals. getApiKeyAndHeaders
stays and wins per-field over provider-resolved auth, but is no longer
required: without it, requests resolve through provider auth.

compact()/generateSummary()/generateBranchSummary() take a Models
parameter; explicit apiKey becomes optional. StreamFn is redefined
structurally (Models.streamSimple satisfies it), dropping the compat
type dependency from agent types.

Harness tests build per-file Models collections with fauxProvider()
and unique provider ids instead of mutating the global api-registry.
This commit is contained in:
Mario Zechner
2026-06-10 21:27:21 +02:00
parent 8a0903ebf2
commit f0ccbbf011
10 changed files with 175 additions and 110 deletions
+12 -14
View File
@@ -1,10 +1,4 @@
import {
type AssistantMessage,
type ImageContent,
type Model,
streamSimple,
type UserMessage,
} from "@earendil-works/pi-ai/compat";
import type { AssistantMessage, ImageContent, Model, Models, UserMessage } from "@earendil-works/pi-ai";
import { runAgentLoop } from "../agent-loop.ts";
import type {
AgentContext,
@@ -178,6 +172,7 @@ export class AgentHarness<
> {
readonly env: ExecutionEnv;
private session: Session;
readonly models: Models;
private phase: AgentHarnessPhase = "idle";
private runAbortController?: AbortController;
private runPromise?: Promise<void>;
@@ -200,6 +195,7 @@ export class AgentHarness<
constructor(options: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>) {
this.env = options.env;
this.session = options.session;
this.models = options.models;
this.resources = options.resources ?? {};
this.streamOptions = cloneStreamOptions(options.streamOptions);
this.systemPrompt = options.systemPrompt;
@@ -382,7 +378,7 @@ export class AgentHarness<
headers: mergeHeaders(turnState.streamOptions.headers, auth?.headers),
};
const requestOptions = await this.emitBeforeProviderRequest(model, turnState.sessionId, snapshotOptions);
return streamSimple(model, context, {
return this.models.streamSimple(model, context, {
cacheRetention: requestOptions.cacheRetention,
headers: requestOptions.headers,
maxRetries: requestOptions.maxRetries,
@@ -713,8 +709,8 @@ export class AgentHarness<
try {
const model = this.model;
if (!model) throw new AgentHarnessError("invalid_state", "No model set for compaction");
// Explicit auth wins; otherwise the request resolves through provider auth.
const auth = await this.getApiKeyAndHeaders?.(model);
if (!auth) throw new AgentHarnessError("auth", "No auth available for compaction");
const branchEntries = await this.session.getBranch();
const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS);
if (!preparationResult.ok) throw preparationResult.error;
@@ -733,9 +729,10 @@ export class AgentHarness<
? { ok: true as const, value: provided }
: await compact(
preparation,
this.models,
model,
auth.apiKey,
auth.headers,
auth?.apiKey,
auth?.headers,
customInstructions,
undefined,
this.thinkingLevel,
@@ -792,12 +789,13 @@ export class AgentHarness<
if (!summaryText && options?.summarize && entries.length > 0) {
const model = this.model;
if (!model) throw new AgentHarnessError("invalid_state", "No model set for branch summary");
// Explicit auth wins; otherwise the request resolves through provider auth.
const auth = await this.getApiKeyAndHeaders?.(model);
if (!auth) throw new AgentHarnessError("auth", "No auth available for branch summary");
const branchSummary = await generateBranchSummary(entries, {
models: this.models,
model,
apiKey: auth.apiKey,
headers: auth.headers,
apiKey: auth?.apiKey,
headers: auth?.headers,
signal: new AbortController().signal,
customInstructions: hookResult?.customInstructions ?? options?.customInstructions,
replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions,
@@ -1,5 +1,5 @@
import type { Model } from "@earendil-works/pi-ai/compat";
import { completeSimple } from "@earendil-works/pi-ai/compat";
import type { Model, Models } from "@earendil-works/pi-ai";
import type { AgentMessage } from "../../types.ts";
import {
convertToLlm,
@@ -49,10 +49,12 @@ export interface CollectEntriesResult {
/** Options for generating a branch summary. */
export interface GenerateBranchSummaryOptions {
/** Provider collection the summarization request goes through. */
models: Models;
/** Model used for summarization. */
model: Model<any>;
/** API key forwarded to the provider. */
apiKey: string;
/** Explicit API key; wins over provider-resolved auth. */
apiKey?: string;
/** Optional request headers forwarded to the provider. */
headers?: Record<string, string>;
/** Abort signal for the summarization request. */
@@ -202,7 +204,16 @@ export async function generateBranchSummary(
entries: SessionTreeEntry[],
options: GenerateBranchSummaryOptions,
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
const { model, apiKey, headers, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;
const {
models,
model,
apiKey,
headers,
signal,
customInstructions,
replaceInstructions,
reserveTokens = 16384,
} = options;
const contextWindow = model.contextWindow || 128000;
const tokenBudget = contextWindow - reserveTokens;
@@ -230,7 +241,7 @@ export async function generateBranchSummary(
timestamp: Date.now(),
},
];
const response = await completeSimple(
const response = await models.completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ apiKey, headers, signal, maxTokens: 2048 },
@@ -1,5 +1,4 @@
import type { AssistantMessage, ImageContent, Model, TextContent, Usage } from "@earendil-works/pi-ai/compat";
import { completeSimple } from "@earendil-works/pi-ai/compat";
import type { AssistantMessage, ImageContent, Model, Models, TextContent, Usage } from "@earendil-works/pi-ai";
import type { AgentMessage, ThinkingLevel } from "../../types.ts";
import {
convertToLlm,
@@ -455,9 +454,10 @@ Keep each section concise. Preserve exact file paths, function names, and error
/** Generate or update a conversation summary for compaction. */
export async function generateSummary(
currentMessages: AgentMessage[],
models: Models,
model: Model<any>,
reserveTokens: number,
apiKey: string,
apiKey?: string,
headers?: Record<string, string>,
signal?: AbortSignal,
customInstructions?: string,
@@ -493,7 +493,7 @@ export async function generateSummary(
? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel }
: { maxTokens, signal, apiKey, headers };
const response = await completeSimple(
const response = await models.completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions,
@@ -626,8 +626,9 @@ export { serializeConversation } from "./utils.ts";
/** Generate compaction summary data from prepared session history. */
export async function compact(
preparation: CompactionPreparation,
models: Models,
model: Model<any>,
apiKey: string,
apiKey?: string,
headers?: Record<string, string>,
customInstructions?: string,
signal?: AbortSignal,
@@ -655,6 +656,7 @@ export async function compact(
messagesToSummarize.length > 0
? generateSummary(
messagesToSummarize,
models,
model,
settings.reserveTokens,
apiKey,
@@ -667,6 +669,7 @@ export async function compact(
: Promise.resolve(ok<string, CompactionError>("No prior history.")),
generateTurnPrefixSummary(
turnPrefixMessages,
models,
model,
settings.reserveTokens,
apiKey,
@@ -681,6 +684,7 @@ export async function compact(
} else {
const summaryResult = await generateSummary(
messagesToSummarize,
models,
model,
settings.reserveTokens,
apiKey,
@@ -706,9 +710,10 @@ export async function compact(
}
async function generateTurnPrefixSummary(
messages: AgentMessage[],
models: Models,
model: Model<any>,
reserveTokens: number,
apiKey: string,
apiKey?: string,
headers?: Record<string, string>,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
@@ -728,7 +733,7 @@ async function generateTurnPrefixSummary(
},
];
const response = await completeSimple(
const response = await models.completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
model.reasoning && thinkingLevel && thinkingLevel !== "off"
+7 -1
View File
@@ -1,4 +1,4 @@
import type { ImageContent, Model, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai";
import type { ImageContent, Model, Models, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai";
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.ts";
import type { Session } from "./session/session.ts";
@@ -802,6 +802,12 @@ export interface AgentHarnessOptions<
> {
env: ExecutionEnv;
session: Session;
/**
* Provider collection used for all model requests (turn streaming,
* compaction, branch summarization). Auth resolves through the providers'
* auth; explicit per-request values (`getApiKeyAndHeaders`) win per field.
*/
models: Models;
tools?: TTool[];
/**
* Concrete resources available to explicit invocation methods and system-prompt callbacks.
+10 -5
View File
@@ -1,19 +1,22 @@
import type {
Api,
AssistantMessage,
AssistantMessageEvent,
AssistantMessageEventStream,
Context,
ImageContent,
Message,
Model,
SimpleStreamOptions,
streamSimple,
TextContent,
Tool,
ToolResultMessage,
} from "@earendil-works/pi-ai/compat";
} from "@earendil-works/pi-ai";
import type { Static, TSchema } from "typebox";
/**
* Stream function used by the agent loop.
* Stream function used by the agent loop. `Models.streamSimple` satisfies
* this shape.
*
* Contract:
* - Must not throw or return a rejected promise for request/model/runtime failures.
@@ -22,8 +25,10 @@ import type { Static, TSchema } from "typebox";
* final AssistantMessage with stopReason "error" or "aborted" and errorMessage.
*/
export type StreamFn = (
...args: Parameters<typeof streamSimple>
) => ReturnType<typeof streamSimple> | Promise<ReturnType<typeof streamSimple>>;
model: Model<Api>,
context: Context,
options?: SimpleStreamOptions,
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
/**
* Configuration for how tool calls from a single assistant message are executed.