@modelprofile.com/flexharness 10.13.0 → 10.15.0

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.
@@ -25,6 +25,9 @@ When constructing the summary, try to stick to this template:
25
25
  /** The instructions of a summary call whose caller names none of the conversation's own. */
26
26
  const COMPACTION_INSTRUCTIONS = 'You summarize a conversation so that another agent can continue its work.';
27
27
 
28
+ /** Added to the summary request when it carries the conversation's tools. */
29
+ const NO_TOOL_CALLS = 'Do not call any tool. Reply with the summary text only.';
30
+
28
31
  export interface ICompactMessagesOptions {
29
32
  abortSignal?: AbortSignal;
30
33
  /**
@@ -45,13 +48,51 @@ export interface ICompactMessagesOptions {
45
48
  * options' `providerOptions` through.
46
49
  */
47
50
  providerOptions?: plugins.ProviderOptions;
51
+ /**
52
+ * The tools the conversation's turns are sent with. The summary call carries their definitions,
53
+ * none of their code, so its request repeats the turns' prefix and reads it from the provider's
54
+ * prompt cache, unless the definitions outweigh the history nine times, when reading them would
55
+ * cost more than the history saves. A context compactor passes its options' `tools` through.
56
+ */
57
+ tools?: plugins.ToolSet;
58
+ }
59
+
60
+ /**
61
+ * Whether the summary request should carry the turns' tool definitions. A cached prefix is read at
62
+ * about a tenth of the input price (Anthropic and OpenAI), so repeating the turns' prefix costs about
63
+ * 0.1 x (tools + history), while a request without the tools reads nothing and pays the history in
64
+ * full: the tools pay their way while they are less than nine times the history.
65
+ */
66
+ async function toolsRepayTheirPrefix(tools: plugins.ToolSet, messages: plugins.ModelMessage[]): Promise<boolean> {
67
+ let toolBytes = 0;
68
+ for (const [name, definition] of Object.entries(tools)) {
69
+ const schema = definition.type === 'provider' ? definition.args : await plugins.asSchema(definition.inputSchema).jsonSchema;
70
+ toolBytes += name.length + JSON.stringify([definition.description ?? '', schema ?? null]).length;
71
+ }
72
+ return toolBytes <= 9 * JSON.stringify(messages).length;
73
+ }
74
+
75
+ /** A tool set's definitions without anything that runs: no `execute`, no input callbacks. */
76
+ function toolDefinitions(tools: plugins.ToolSet): plugins.ToolSet {
77
+ return Object.fromEntries(Object.entries(tools).map(([name, definition]) => {
78
+ const {
79
+ execute: _execute,
80
+ onInputStart: _onInputStart,
81
+ onInputDelta: _onInputDelta,
82
+ onInputAvailable: _onInputAvailable,
83
+ ...rest
84
+ } = definition as plugins.ToolSet[string] & Record<string, unknown>;
85
+ return [name, rest as plugins.ToolSet[string]];
86
+ }));
48
87
  }
49
88
 
50
89
  /**
51
90
  * Compacts a message history into a summary.
52
91
  *
53
92
  * The summary is one streamed, instructed model call, the shape every agent turn has, so it reaches
54
- * a provider that serves only that shape (the ChatGPT Codex backend) the way the turns do.
93
+ * a provider that serves only that shape (the ChatGPT Codex backend) the way the turns do. With the
94
+ * turns' `tools` it also repeats their prefix, which the provider has cached; a model that answers
95
+ * it with a tool call instead of a summary is asked once more with tool use turned off.
55
96
  *
56
97
  * @param model The same model used by runAgent, or a cheaper small model
57
98
  * @param messages The full message history that overflowed
@@ -63,9 +104,12 @@ export async function compactMessages(
63
104
  messages: plugins.ModelMessage[],
64
105
  options: ICompactMessagesOptions = {},
65
106
  ): Promise<plugins.ModelMessage[]> {
107
+ const tools = options.tools && Object.keys(options.tools).length > 0 && await toolsRepayTheirPrefix(options.tools, messages)
108
+ ? toolDefinitions(options.tools)
109
+ : undefined;
66
110
  const prompt: plugins.ModelMessage[] = [
67
111
  ...messages,
68
- { role: 'user', content: COMPACTION_PROMPT },
112
+ { role: 'user', content: tools ? `${COMPACTION_PROMPT}\n\n${NO_TOOL_CALLS}` : COMPACTION_PROMPT },
69
113
  ];
70
114
  const reportUsage = options.reportUsage;
71
115
  const modelCalls = reportUsage
@@ -89,38 +133,50 @@ export async function compactMessages(
89
133
  },
90
134
  })
91
135
  : model;
92
- let streamError: unknown;
93
- try {
94
- const result = plugins.streamText({
95
- model: observedModel,
96
- instructions: options.system ?? COMPACTION_INSTRUCTIONS,
97
- messages: prompt,
98
- allowSystemInMessages: true,
99
- ...(options.providerOptions ? { providerOptions: options.providerOptions } : {}),
100
- abortSignal: options.abortSignal,
101
- onError: ({ error }) => {
102
- streamError ??= error;
103
- },
104
- ...(modelCalls
105
- ? {
106
- onLanguageModelCallEnd: ({ modelId, usage }) => {
107
- modelCalls.end(modelId, usage);
108
- },
109
- }
110
- : {}),
111
- });
112
- let summary = '';
113
- for await (const delta of result.textStream) summary += delta;
114
- // A stream that fails delivers its error to onError and ends, and an aborted stream just ends;
115
- // either way the summary is incomplete and must not replace the history.
116
- if (streamError !== undefined) throw streamError;
117
- options.abortSignal?.throwIfAborted();
118
- modelCalls?.settle('missing');
119
- return summaryMessages(summary);
120
- } catch (error) {
121
- modelCalls?.settle(options.abortSignal?.aborted ? 'aborted' : 'failed');
122
- throw streamError ?? error;
123
- }
136
+ // The summary is asked for in the shape of a turn, the tools offered as the turns offer them. A
137
+ // model that calls a tool instead of answering is asked again with tool use turned off, which
138
+ // keeps the tool definitions and so still the cached tools and instructions.
139
+ const summarize = async (toolChoice?: 'none'): Promise<{ summary: string; calledTools: boolean }> => {
140
+ let streamError: unknown;
141
+ try {
142
+ const result = plugins.streamText({
143
+ model: observedModel,
144
+ instructions: options.system ?? COMPACTION_INSTRUCTIONS,
145
+ messages: prompt,
146
+ allowSystemInMessages: true,
147
+ ...(tools ? { tools, ...(toolChoice ? { toolChoice } : {}) } : {}),
148
+ ...(options.providerOptions ? { providerOptions: options.providerOptions } : {}),
149
+ abortSignal: options.abortSignal,
150
+ onError: ({ error }) => {
151
+ streamError ??= error;
152
+ },
153
+ ...(modelCalls
154
+ ? {
155
+ onLanguageModelCallEnd: ({ modelId, usage }) => {
156
+ modelCalls.end(modelId, usage);
157
+ },
158
+ }
159
+ : {}),
160
+ });
161
+ let summary = '';
162
+ for await (const delta of result.textStream) summary += delta;
163
+ // A stream that fails delivers its error to onError and ends, and an aborted stream just ends;
164
+ // either way the summary is incomplete and must not replace the history.
165
+ if (streamError !== undefined) throw streamError;
166
+ options.abortSignal?.throwIfAborted();
167
+ modelCalls?.settle('missing');
168
+ return { summary, calledTools: tools !== undefined && (await result.toolCalls).length > 0 };
169
+ } catch (error) {
170
+ modelCalls?.settle(options.abortSignal?.aborted ? 'aborted' : 'failed');
171
+ throw streamError ?? error;
172
+ }
173
+ };
174
+ // A provider-executed tool, such as a web search, would run on the provider's side before the
175
+ // retry could prevent it: with one in the set, tool use is off from the start.
176
+ const providerExecuted = tools !== undefined && Object.values(tools).some((definition) => definition.type === 'provider');
177
+ let { summary, calledTools } = await summarize(providerExecuted ? 'none' : undefined);
178
+ if (calledTools && !summary.trim()) ({ summary } = await summarize('none'));
179
+ return summaryMessages(summary);
124
180
  }
125
181
 
126
182
  function summaryMessages(summary: string): plugins.ModelMessage[] {