@ixo/oracle-runtime-workers 0.10.0 → 0.11.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.
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/core/context-budget.ts +201 -0
- package/src/core/context-window.ts +289 -0
- package/src/core/env.ts +29 -0
- package/src/core/main-agent-types.ts +46 -0
- package/src/core/main-agent.ts +86 -9
- package/src/core/middlewares/context-guard.ts +270 -0
- package/src/core/middlewares/result-cap.ts +180 -0
- package/src/core/middlewares/summarization.ts +87 -6
- package/src/core/middlewares/tool-marks.ts +209 -0
- package/src/core/openrouter-pricing.ts +42 -1
- package/src/core/read-result-tool.ts +77 -0
- package/src/core/runtime-context.ts +7 -0
- package/src/core/sub-agent-fallback.ts +20 -3
- package/src/core/wrap-plugin-tool.ts +24 -3
- package/src/do/ambient.ts +17 -0
- package/src/do/contracts.ts +227 -0
- package/src/do/result-store-test-do.ts +88 -0
- package/src/do/result-store.ts +339 -0
- package/src/do/run-buffer.ts +204 -0
- package/src/do/run-coordinator.ts +572 -0
- package/src/do/run-request.ts +52 -0
- package/src/do/run-store-test-do.ts +130 -0
- package/src/do/run-store.ts +604 -0
- package/src/do/session-id-migration.ts +85 -0
- package/src/do/speaker-prefix.ts +37 -0
- package/src/do/sse-stream.ts +567 -400
- package/src/do/transcript.ts +238 -0
- package/src/do/turn-body.ts +10 -0
- package/src/do/user-oracle-do.ts +875 -146
- package/src/matrix/gateway-do.ts +230 -29
- package/src/matrix/group-chat-summarizer.ts +107 -0
- package/src/matrix/group-chat.ts +1163 -0
- package/src/matrix/inbox-store.ts +23 -3
- package/src/matrix/ingest.ts +54 -11
- package/src/matrix/reply-chain.ts +34 -25
- package/src/plugin-api/tool-helper.ts +9 -1
- package/src/plugin-api/types.ts +86 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/matrix-group-chats/README.md +70 -0
- package/src/plugins/matrix-group-chats/index.ts +132 -0
- package/src/plugins/matrix-group-chats/tools.ts +172 -0
- package/src/plugins/tasks/tasks-tools.ts +4 -0
- package/src/shell/app.ts +66 -0
- package/src/sqlite/sqlite-saver.ts +81 -0
- package/src/tasks/scheduler.ts +134 -3
- package/src/tasks/store.ts +22 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
|
2
2
|
import type { BaseMessage } from '@langchain/core/messages';
|
|
3
3
|
import { type AgentMiddleware, summarizationMiddleware } from 'langchain';
|
|
4
|
+
import type { Logger } from '../../plugin-api/types';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* IXO-flavoured prompt instructing the summarizer to preserve identifiers
|
|
@@ -56,15 +57,41 @@ const DEFAULT_KEEP_MESSAGES = 10;
|
|
|
56
57
|
export interface SummarizationMiddlewareOptions {
|
|
57
58
|
/** Model used to generate the summary (typically a small/cheap router model). */
|
|
58
59
|
model: BaseChatModel;
|
|
59
|
-
/** Override the trigger threshold for messages (default: 20). */
|
|
60
|
-
triggerMessages?: number;
|
|
61
60
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
* Trigger threshold in messages. Default 20 when no `triggerTokens` is
|
|
62
|
+
* given (the legacy pair); `null` disables the message trigger, which is
|
|
63
|
+
* what a window-derived `triggerTokens` wants (a tool turn is ~4 messages,
|
|
64
|
+
* so 20 fires after five turns whatever the model's window).
|
|
65
|
+
*/
|
|
66
|
+
triggerMessages?: number | null;
|
|
67
|
+
/**
|
|
68
|
+
* Trigger on approximate token count (chars/4). Default 40k; the main
|
|
69
|
+
* agent passes a fraction of the model's context window.
|
|
64
70
|
*/
|
|
65
71
|
triggerTokens?: number;
|
|
66
72
|
/** Override the number of recent messages to keep (default: 10). */
|
|
67
73
|
keepMessages?: number;
|
|
74
|
+
/** What the summarizer itself may read (tokens); default LangChain's. */
|
|
75
|
+
summaryInputTokens?: number;
|
|
76
|
+
logger?: Pick<Logger, 'warn' | 'log'>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* LangChain's summarizer swallows a failed summary call and returns
|
|
81
|
+
* `Error generating summary: …` as the summary text — which would then
|
|
82
|
+
* REPLACE the conversation history with an error string (the model's next
|
|
83
|
+
* reply reads "I only received an error in the conversation summary").
|
|
84
|
+
*/
|
|
85
|
+
const FAILED_SUMMARY = /^Error generating summary:/;
|
|
86
|
+
|
|
87
|
+
export function isFailedSummary(message: BaseMessage): boolean {
|
|
88
|
+
if (!isSummarizationMessage(message)) return false;
|
|
89
|
+
const { content } = message;
|
|
90
|
+
if (typeof content !== 'string') return false;
|
|
91
|
+
const body = content.startsWith(SUMMARY_PREFIX)
|
|
92
|
+
? content.slice(SUMMARY_PREFIX.length).trimStart()
|
|
93
|
+
: content;
|
|
94
|
+
return FAILED_SUMMARY.test(body);
|
|
68
95
|
}
|
|
69
96
|
|
|
70
97
|
/**
|
|
@@ -86,14 +113,68 @@ export function isSummarizationMessage(message: BaseMessage): boolean {
|
|
|
86
113
|
export const createSummarizationMiddleware = (
|
|
87
114
|
options: SummarizationMiddlewareOptions,
|
|
88
115
|
): AgentMiddleware => {
|
|
89
|
-
|
|
116
|
+
const triggerMessages =
|
|
117
|
+
options.triggerMessages === null
|
|
118
|
+
? null
|
|
119
|
+
: (options.triggerMessages ??
|
|
120
|
+
(options.triggerTokens === undefined
|
|
121
|
+
? DEFAULT_TRIGGER_MESSAGES
|
|
122
|
+
: null));
|
|
123
|
+
const inner = summarizationMiddleware({
|
|
90
124
|
model: options.model,
|
|
91
125
|
summaryPrompt: SUMMARY_PROMPT,
|
|
92
126
|
summaryPrefix: SUMMARY_PREFIX,
|
|
93
127
|
trigger: [
|
|
94
|
-
{ messages:
|
|
128
|
+
...(triggerMessages !== null ? [{ messages: triggerMessages }] : []),
|
|
95
129
|
{ tokens: options.triggerTokens ?? DEFAULT_TRIGGER_TOKENS },
|
|
96
130
|
],
|
|
97
131
|
keep: { messages: options.keepMessages ?? DEFAULT_KEEP_MESSAGES },
|
|
132
|
+
...(options.summaryInputTokens !== undefined
|
|
133
|
+
? { trimTokensToSummarize: options.summaryInputTokens }
|
|
134
|
+
: {}),
|
|
98
135
|
});
|
|
136
|
+
const beforeModel = inner.beforeModel;
|
|
137
|
+
if (!beforeModel) return inner;
|
|
138
|
+
// The hook is either a bare handler or `{ hook, canJumpTo }`; keep the shape.
|
|
139
|
+
const handler =
|
|
140
|
+
typeof beforeModel === 'function' ? beforeModel : beforeModel.hook;
|
|
141
|
+
// A summary that failed keeps the history exactly as it was: the turn
|
|
142
|
+
// runs on the full context and the next turn tries again.
|
|
143
|
+
const guarded: typeof handler = async (state, runtime) => {
|
|
144
|
+
const update = await handler(state, runtime);
|
|
145
|
+
const messages =
|
|
146
|
+
update && typeof update === 'object' && 'messages' in update
|
|
147
|
+
? (update as { messages?: unknown }).messages
|
|
148
|
+
: undefined;
|
|
149
|
+
const failed = Array.isArray(messages)
|
|
150
|
+
? messages.find((m) => isBaseMessageLike(m) && isFailedSummary(m))
|
|
151
|
+
: undefined;
|
|
152
|
+
if (failed) {
|
|
153
|
+
options.logger?.warn(
|
|
154
|
+
`[summarization] summary failed; keeping the full history this turn: ${String(failed.content).slice(0, 300)}`,
|
|
155
|
+
);
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
if (Array.isArray(messages) && messages.length > 0)
|
|
159
|
+
options.logger?.log(
|
|
160
|
+
`[summarization] condensed the history: ${state.messages.length} messages → a summary + ${messages.length - 2} kept`,
|
|
161
|
+
);
|
|
162
|
+
return update;
|
|
163
|
+
};
|
|
164
|
+
return {
|
|
165
|
+
...inner,
|
|
166
|
+
beforeModel:
|
|
167
|
+
typeof beforeModel === 'function'
|
|
168
|
+
? guarded
|
|
169
|
+
: { ...beforeModel, hook: guarded },
|
|
170
|
+
};
|
|
99
171
|
};
|
|
172
|
+
|
|
173
|
+
function isBaseMessageLike(value: unknown): value is BaseMessage {
|
|
174
|
+
return (
|
|
175
|
+
typeof value === 'object' &&
|
|
176
|
+
value !== null &&
|
|
177
|
+
'content' in value &&
|
|
178
|
+
'additional_kwargs' in value
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-ahead marks for tool calls, and the resume policy built on them.
|
|
3
|
+
*
|
|
4
|
+
* Before any tool executes, its call id is recorded (`started`) in the run's
|
|
5
|
+
* marks; when it returns, the mark is closed (`done`). A run that is resumed
|
|
6
|
+
* after a reset finds the marks of the previous attempt and the graph about
|
|
7
|
+
* to execute the same tool calls again (LangGraph re-runs the pending node).
|
|
8
|
+
* The policy:
|
|
9
|
+
*
|
|
10
|
+
* - a call that never started runs normally;
|
|
11
|
+
* - a started, unfinished **write** call is NOT executed again — the model
|
|
12
|
+
* gets a synthetic result saying the outcome is unknown, so it can tell
|
|
13
|
+
* the user or verify with a read;
|
|
14
|
+
* - a started, unfinished **read** call runs again (it has no effect);
|
|
15
|
+
* - a call that already finished is not executed again either (its result
|
|
16
|
+
* is in the checkpoint's pending writes; reaching here means LangGraph
|
|
17
|
+
* could not replay it) — writes get the unknown-outcome result, reads run.
|
|
18
|
+
*
|
|
19
|
+
* The middleware also injects, into the first model call after a resume, a
|
|
20
|
+
* note carrying the tail of the reply the user had already seen, so the
|
|
21
|
+
* model continues the interrupted reply instead of starting over.
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
type BaseMessage,
|
|
25
|
+
SystemMessage,
|
|
26
|
+
ToolMessage,
|
|
27
|
+
} from '@langchain/core/messages';
|
|
28
|
+
import { type AgentMiddleware, createMiddleware } from 'langchain';
|
|
29
|
+
import type { ToolEffect, ToolMark } from '../../do/run-store';
|
|
30
|
+
import type { Logger } from '../../plugin-api/types';
|
|
31
|
+
import { NOOP_LOGGER } from '../utils';
|
|
32
|
+
|
|
33
|
+
export type { ToolEffect };
|
|
34
|
+
|
|
35
|
+
/** The mark store slice the middleware drives (see `RunStore`). */
|
|
36
|
+
export interface ToolMarkStore {
|
|
37
|
+
startMark(input: {
|
|
38
|
+
runId: string;
|
|
39
|
+
toolCallId: string;
|
|
40
|
+
toolName: string;
|
|
41
|
+
effect: ToolEffect;
|
|
42
|
+
}): Promise<ToolMark | undefined>;
|
|
43
|
+
bumpMark(runId: string, toolCallId: string): Promise<void>;
|
|
44
|
+
finishMark(
|
|
45
|
+
runId: string,
|
|
46
|
+
toolCallId: string,
|
|
47
|
+
outcome: 'ok' | 'error' | 'interrupted',
|
|
48
|
+
): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ToolMarksOptions {
|
|
52
|
+
runId: string;
|
|
53
|
+
store: ToolMarkStore;
|
|
54
|
+
/** The effect of a tool by name; unknown tools are writes (see `toolEffectOf`). */
|
|
55
|
+
effectOf: (toolName: string) => ToolEffect;
|
|
56
|
+
/**
|
|
57
|
+
* The reply text the user already received before the run was cut off.
|
|
58
|
+
* Set only on a resumed attempt; consumed by the first model call.
|
|
59
|
+
*/
|
|
60
|
+
continuation?: string | null;
|
|
61
|
+
logger?: Logger;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The result handed to the model for a write call whose outcome cannot be known. */
|
|
65
|
+
export function unknownOutcomeToolResult(toolName: string): string {
|
|
66
|
+
return `The runtime was restarted while ${toolName} was executing, so whether it completed is unknown and it was NOT run again (running it twice could repeat its effect). Tell the user this step may or may not have happened, and verify with a read-only call before repeating it.`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** How many trailing characters of the interrupted reply the model is shown. */
|
|
70
|
+
export const CONTINUATION_TAIL_CHARS = 1200;
|
|
71
|
+
|
|
72
|
+
export function continuationNote(partialText: string): string {
|
|
73
|
+
const tail =
|
|
74
|
+
partialText.length > CONTINUATION_TAIL_CHARS
|
|
75
|
+
? `…${partialText.slice(-CONTINUATION_TAIL_CHARS)}`
|
|
76
|
+
: partialText;
|
|
77
|
+
return (
|
|
78
|
+
'Your reply to the latest user message was interrupted by a runtime restart. ' +
|
|
79
|
+
'The user has already received this beginning of it:\n\n' +
|
|
80
|
+
`"""${tail}"""\n\n` +
|
|
81
|
+
'Continue from exactly where it stopped, without repeating any of the text above, ' +
|
|
82
|
+
'without apologising and without mentioning the interruption. If the reply above ' +
|
|
83
|
+
'was already complete, respond with nothing more than a one-line closing remark.'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const READ_PREFIXES = [
|
|
88
|
+
'list_',
|
|
89
|
+
'get_',
|
|
90
|
+
'search_',
|
|
91
|
+
'read_',
|
|
92
|
+
'preview_',
|
|
93
|
+
'describe_',
|
|
94
|
+
'check_',
|
|
95
|
+
'explain_',
|
|
96
|
+
'validate_',
|
|
97
|
+
'view_',
|
|
98
|
+
];
|
|
99
|
+
const READ_NAMES = new Set([
|
|
100
|
+
'requirements',
|
|
101
|
+
'flow_status',
|
|
102
|
+
'compatible_actions',
|
|
103
|
+
'oracle_list',
|
|
104
|
+
'vfs_glob',
|
|
105
|
+
'vfs_grep',
|
|
106
|
+
'load_capability',
|
|
107
|
+
'unload_capability',
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Effect classification: an explicit declaration wins; MCP annotations count
|
|
112
|
+
* when the server sent them; otherwise a conservative name convention marks
|
|
113
|
+
* the obvious reads and everything else is a write (never re-run on resume).
|
|
114
|
+
*/
|
|
115
|
+
export function toolEffectOf(tool: {
|
|
116
|
+
name: string;
|
|
117
|
+
effect?: ToolEffect;
|
|
118
|
+
annotations?: { readOnlyHint?: boolean; idempotentHint?: boolean };
|
|
119
|
+
}): ToolEffect {
|
|
120
|
+
if (tool.effect) return tool.effect;
|
|
121
|
+
if (tool.annotations?.readOnlyHint === true) return 'read';
|
|
122
|
+
if (READ_NAMES.has(tool.name)) return 'read';
|
|
123
|
+
// MCP tools arrive as `<server>__<tool>`; classify on the tool part.
|
|
124
|
+
const bare = tool.name.includes('__')
|
|
125
|
+
? tool.name.slice(tool.name.lastIndexOf('__') + 2)
|
|
126
|
+
: tool.name;
|
|
127
|
+
if (READ_NAMES.has(bare)) return 'read';
|
|
128
|
+
for (const prefix of READ_PREFIXES)
|
|
129
|
+
if (bare.startsWith(prefix)) return 'read';
|
|
130
|
+
return 'write';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isErrorResult(output: unknown): boolean {
|
|
134
|
+
if (ToolMessage.isInstance(output)) return output.status === 'error';
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function createToolMarksMiddleware(
|
|
139
|
+
options: ToolMarksOptions,
|
|
140
|
+
): AgentMiddleware {
|
|
141
|
+
const logger = options.logger ?? NOOP_LOGGER;
|
|
142
|
+
let continuation = options.continuation ?? null;
|
|
143
|
+
return createMiddleware({
|
|
144
|
+
name: 'ToolMarksMiddleware',
|
|
145
|
+
wrapModelCall: async (request, handler) => {
|
|
146
|
+
if (!continuation || continuation.trim().length === 0)
|
|
147
|
+
return handler(request);
|
|
148
|
+
const note = continuationNote(continuation);
|
|
149
|
+
continuation = null;
|
|
150
|
+
const messages: BaseMessage[] = [
|
|
151
|
+
...request.messages,
|
|
152
|
+
new SystemMessage(note),
|
|
153
|
+
];
|
|
154
|
+
logger.log(
|
|
155
|
+
'[tool-marks] resumed run: continuation note added to the first model call',
|
|
156
|
+
);
|
|
157
|
+
return handler({ ...request, messages });
|
|
158
|
+
},
|
|
159
|
+
wrapToolCall: async (request, handler) => {
|
|
160
|
+
const { toolCall } = request;
|
|
161
|
+
const toolCallId = toolCall.id ?? '';
|
|
162
|
+
const toolName = toolCall.name;
|
|
163
|
+
if (!toolCallId) return handler(request);
|
|
164
|
+
const effect = options.effectOf(toolName);
|
|
165
|
+
const existing = await options.store.startMark({
|
|
166
|
+
runId: options.runId,
|
|
167
|
+
toolCallId,
|
|
168
|
+
toolName,
|
|
169
|
+
effect,
|
|
170
|
+
});
|
|
171
|
+
if (existing) {
|
|
172
|
+
if (effect === 'write') {
|
|
173
|
+
logger.warn(
|
|
174
|
+
`[tool-marks] ${toolName} (${toolCallId}) was ${existing.doneAt ? 'already executed' : 'interrupted mid-execution'} in an earlier attempt of run ${options.runId}; not run again`,
|
|
175
|
+
);
|
|
176
|
+
await options.store.finishMark(
|
|
177
|
+
options.runId,
|
|
178
|
+
toolCallId,
|
|
179
|
+
'interrupted',
|
|
180
|
+
);
|
|
181
|
+
return new ToolMessage({
|
|
182
|
+
tool_call_id: toolCallId,
|
|
183
|
+
name: toolName,
|
|
184
|
+
content: unknownOutcomeToolResult(toolName),
|
|
185
|
+
status: 'error',
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
logger.log(
|
|
189
|
+
`[tool-marks] ${toolName} (${toolCallId}) is read-only; running it again after the interruption`,
|
|
190
|
+
);
|
|
191
|
+
await options.store.bumpMark(options.runId, toolCallId);
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const output = await handler(request);
|
|
195
|
+
await options.store.finishMark(
|
|
196
|
+
options.runId,
|
|
197
|
+
toolCallId,
|
|
198
|
+
isErrorResult(output) ? 'error' : 'ok',
|
|
199
|
+
);
|
|
200
|
+
return output;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
await options.store
|
|
203
|
+
.finishMark(options.runId, toolCallId, 'error')
|
|
204
|
+
.catch(() => undefined);
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -33,6 +33,14 @@ const openRouterModelsSchema = z.object({
|
|
|
33
33
|
completion: z.string().optional(),
|
|
34
34
|
})
|
|
35
35
|
.optional(),
|
|
36
|
+
// The model's context window in tokens; `top_provider.context_length`
|
|
37
|
+
// is the window of the provider OpenRouter routes to by default and
|
|
38
|
+
// can be smaller than the model's nominal one — the smaller wins.
|
|
39
|
+
context_length: z.number().nullable().optional(),
|
|
40
|
+
top_provider: z
|
|
41
|
+
.object({ context_length: z.number().nullable().optional() })
|
|
42
|
+
.nullable()
|
|
43
|
+
.optional(),
|
|
36
44
|
}),
|
|
37
45
|
),
|
|
38
46
|
});
|
|
@@ -40,10 +48,26 @@ const openRouterModelsSchema = z.object({
|
|
|
40
48
|
interface PriceCache {
|
|
41
49
|
fetchedAt: number;
|
|
42
50
|
prices: Map<string, ModelPrice>;
|
|
51
|
+
contextLengths: Map<string, number>;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
let cache: PriceCache | null = null;
|
|
46
55
|
|
|
56
|
+
function parseContextLengths(
|
|
57
|
+
data: z.infer<typeof openRouterModelsSchema>['data'],
|
|
58
|
+
): Map<string, number> {
|
|
59
|
+
const out = new Map<string, number>();
|
|
60
|
+
for (const model of data) {
|
|
61
|
+
const candidates = [
|
|
62
|
+
model.context_length,
|
|
63
|
+
model.top_provider?.context_length,
|
|
64
|
+
].filter((n): n is number => typeof n === 'number' && n > 0);
|
|
65
|
+
if (candidates.length === 0) continue;
|
|
66
|
+
out.set(model.id, Math.floor(Math.min(...candidates)));
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
47
71
|
/** Convert an OpenRouter `$/token` string to `$/million tokens`, or `null`. */
|
|
48
72
|
function perMillion(pricePerToken: string | undefined): number | null {
|
|
49
73
|
if (pricePerToken == null) return null;
|
|
@@ -106,7 +130,11 @@ export async function fetchOpenRouterPrices(
|
|
|
106
130
|
if (prices.size === 0) {
|
|
107
131
|
throw new Error('OpenRouter /models returned no usable pricing');
|
|
108
132
|
}
|
|
109
|
-
cache = {
|
|
133
|
+
cache = {
|
|
134
|
+
fetchedAt: now(),
|
|
135
|
+
prices,
|
|
136
|
+
contextLengths: parseContextLengths(parsed.data.data),
|
|
137
|
+
};
|
|
110
138
|
return prices;
|
|
111
139
|
} catch (error) {
|
|
112
140
|
logger.warn(
|
|
@@ -118,6 +146,19 @@ export async function fetchOpenRouterPrices(
|
|
|
118
146
|
}
|
|
119
147
|
}
|
|
120
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Context windows (tokens) by OpenRouter model id, from the same cached
|
|
151
|
+
* `/models` fetch as the prices (one request serves both). Empty when the
|
|
152
|
+
* listing is unavailable — the resolver then falls back (see
|
|
153
|
+
* `context-window.ts`).
|
|
154
|
+
*/
|
|
155
|
+
export async function fetchOpenRouterContextLengths(
|
|
156
|
+
opts: FetchOpenRouterPricesOptions = {},
|
|
157
|
+
): Promise<ReadonlyMap<string, number>> {
|
|
158
|
+
await fetchOpenRouterPrices(opts);
|
|
159
|
+
return cache?.contextLengths ?? new Map<string, number>();
|
|
160
|
+
}
|
|
161
|
+
|
|
121
162
|
/** Test seam: clear the in-memory price cache between cases. */
|
|
122
163
|
export function resetOpenRouterPriceCache(): void {
|
|
123
164
|
cache = null;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `read_result` — pages through a tool result that was too large for the
|
|
3
|
+
* context (`result-cap.ts` saved it whole and showed the model a head, a
|
|
4
|
+
* tail and the handle). Chunks are byte ranges; `next` is the offset of the
|
|
5
|
+
* following chunk or `null` at the end.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { tool } from '../plugin-api/tool-helper';
|
|
9
|
+
import type { PluginTool } from '../plugin-api/types';
|
|
10
|
+
import type { ReadResultOutcome } from '../do/result-store';
|
|
11
|
+
|
|
12
|
+
export interface ReadResultToolOptions {
|
|
13
|
+
read: (
|
|
14
|
+
id: string,
|
|
15
|
+
offset: number,
|
|
16
|
+
length: number,
|
|
17
|
+
) => Promise<ReadResultOutcome>;
|
|
18
|
+
/** Longest chunk this deployment hands back (kept under the result cap). */
|
|
19
|
+
maxChars: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const READ_RESULT_TOOL_NAME = 'read_result';
|
|
23
|
+
|
|
24
|
+
export function buildReadResultTool(
|
|
25
|
+
options: ReadResultToolOptions,
|
|
26
|
+
): PluginTool {
|
|
27
|
+
const maxChars = Math.max(512, Math.min(options.maxChars, 64 * 1024));
|
|
28
|
+
const defaultChars = Math.min(4_000, maxChars);
|
|
29
|
+
const schema = z.object({
|
|
30
|
+
id: z
|
|
31
|
+
.string()
|
|
32
|
+
.regex(/^[a-f0-9]{64}$/)
|
|
33
|
+
.describe('The saved result id from a "[Result truncated …]" footer.'),
|
|
34
|
+
offset: z
|
|
35
|
+
.number()
|
|
36
|
+
.int()
|
|
37
|
+
.min(0)
|
|
38
|
+
.default(0)
|
|
39
|
+
.describe('Byte offset to read from.'),
|
|
40
|
+
length: z
|
|
41
|
+
.number()
|
|
42
|
+
.int()
|
|
43
|
+
.min(256)
|
|
44
|
+
.max(maxChars)
|
|
45
|
+
.default(defaultChars)
|
|
46
|
+
.describe(`Bytes to read (max ${maxChars}).`),
|
|
47
|
+
});
|
|
48
|
+
return tool(
|
|
49
|
+
async (rawArgs) => {
|
|
50
|
+
const { id, offset, length } = schema.parse(rawArgs);
|
|
51
|
+
const outcome = await options.read(id, offset, length);
|
|
52
|
+
if (outcome.status !== 'ok')
|
|
53
|
+
return {
|
|
54
|
+
error:
|
|
55
|
+
outcome.status === 'expired'
|
|
56
|
+
? 'This saved result has expired; run the tool again to get it back.'
|
|
57
|
+
: 'No saved result with this id in this session.',
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
id: outcome.id,
|
|
61
|
+
offset: outcome.offset,
|
|
62
|
+
length: outcome.length,
|
|
63
|
+
size: outcome.size,
|
|
64
|
+
next: outcome.next,
|
|
65
|
+
text: outcome.text,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: READ_RESULT_TOOL_NAME,
|
|
70
|
+
effect: 'read',
|
|
71
|
+
visibility: 'always',
|
|
72
|
+
description:
|
|
73
|
+
'Read a saved large tool result in chunks. Use the id from a "[Result truncated …]" footer; start at offset 0 and pass the returned `next` as the offset of the following call until `next` is null.',
|
|
74
|
+
schema,
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -81,6 +81,9 @@ export interface MatrixAdapter {
|
|
|
81
81
|
getEventById(roomId: string, eventId: string): Promise<MatrixEvent>;
|
|
82
82
|
/** See `MatrixGatewayObject.botCredentials`. */
|
|
83
83
|
botCredentials(): Promise<BotCredentials>;
|
|
84
|
+
/** Group rooms: room kind and channel memory (see `plugin-api/types.ts`). Hosts without a gateway omit them. */
|
|
85
|
+
roomInfo?: RuntimeContext['matrix']['roomInfo'];
|
|
86
|
+
channelMemory?: RuntimeContext['matrix']['channelMemory'];
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
/** LLM adapter — turns role tags into chat models. */
|
|
@@ -417,6 +420,10 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
|
|
|
417
420
|
getRoomState: (roomId) => ambient.matrix.getRoomState(roomId),
|
|
418
421
|
getEventById: (roomId, eventId) =>
|
|
419
422
|
ambient.matrix.getEventById(roomId, eventId),
|
|
423
|
+
...(ambient.matrix.roomInfo ? { roomInfo: ambient.matrix.roomInfo } : {}),
|
|
424
|
+
...(ambient.matrix.channelMemory
|
|
425
|
+
? { channelMemory: ambient.matrix.channelMemory }
|
|
426
|
+
: {}),
|
|
420
427
|
},
|
|
421
428
|
ucan: {
|
|
422
429
|
hasCapability: (resource, action) =>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { StructuredTool } from 'langchain';
|
|
1
|
+
import type { AgentMiddleware, StructuredTool } from 'langchain';
|
|
2
2
|
import type {
|
|
3
3
|
PluginContext,
|
|
4
4
|
PluginSubAgent,
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
} from './runtime-context';
|
|
15
15
|
import { createSubagentAsTool, type AgentSpec } from './subagent-as-tool';
|
|
16
16
|
import { wrapPluginTool } from './wrap-plugin-tool';
|
|
17
|
+
import type { ResultCapConfig } from './middlewares/result-cap';
|
|
17
18
|
|
|
18
19
|
/** Inputs for collecting and wrapping sub-agents. */
|
|
19
20
|
export interface CollectSubAgentsInput {
|
|
@@ -54,6 +55,10 @@ export interface CollectSubAgentsInput {
|
|
|
54
55
|
* in name collisions.
|
|
55
56
|
*/
|
|
56
57
|
passthroughTools?: StructuredTool[];
|
|
58
|
+
/** Middlewares every sub-agent's inner graph gets in addition to its own (the durable-run tool marks). */
|
|
59
|
+
extraMiddleware?: AgentMiddleware[];
|
|
60
|
+
/** The turn's result cap, applied to every wrapped sub-agent tool (see `wrapPluginTool`). */
|
|
61
|
+
resultCap?: ResultCapConfig;
|
|
57
62
|
/**
|
|
58
63
|
* Optional pre-collected sub-agent list. When provided, the registry is not
|
|
59
64
|
* queried — callers that need to filter the entries collect from the
|
|
@@ -78,6 +83,8 @@ function defaultToAgentSpec(
|
|
|
78
83
|
sessionId: string,
|
|
79
84
|
sharedFactory: ((ctx: RuntimeContext) => SharedAccessors) | undefined,
|
|
80
85
|
fallbackContext: RunConfigContext | undefined,
|
|
86
|
+
extraMiddleware?: AgentMiddleware[],
|
|
87
|
+
resultCap?: ResultCapConfig,
|
|
81
88
|
): AgentSpec {
|
|
82
89
|
const systemPrompt =
|
|
83
90
|
typeof subAgent.systemPrompt === 'function'
|
|
@@ -89,7 +96,13 @@ function defaultToAgentSpec(
|
|
|
89
96
|
: subAgent.tools(buildCtx);
|
|
90
97
|
|
|
91
98
|
const tools: StructuredTool[] = pluginTools.map((t) =>
|
|
92
|
-
wrapPluginTool(t, {
|
|
99
|
+
wrapPluginTool(t, {
|
|
100
|
+
ambient,
|
|
101
|
+
state,
|
|
102
|
+
sharedFactory,
|
|
103
|
+
fallbackContext,
|
|
104
|
+
...(resultCap ? { resultCap } : {}),
|
|
105
|
+
}),
|
|
93
106
|
);
|
|
94
107
|
|
|
95
108
|
const model = ambient.llm.get(subAgent.model ?? 'subagent');
|
|
@@ -112,7 +125,7 @@ function defaultToAgentSpec(
|
|
|
112
125
|
systemPrompt,
|
|
113
126
|
tools,
|
|
114
127
|
model,
|
|
115
|
-
middleware: subAgent.middlewares,
|
|
128
|
+
middleware: [...(extraMiddleware ?? []), ...(subAgent.middlewares ?? [])],
|
|
116
129
|
userDid,
|
|
117
130
|
sessionId,
|
|
118
131
|
logger: ambient.logger,
|
|
@@ -142,6 +155,8 @@ export async function collectSubAgentsWithFallback(
|
|
|
142
155
|
toAgentSpec,
|
|
143
156
|
passthroughTools,
|
|
144
157
|
subAgents,
|
|
158
|
+
extraMiddleware,
|
|
159
|
+
resultCap,
|
|
145
160
|
} = input;
|
|
146
161
|
|
|
147
162
|
const entries = subAgents ?? (await registry.collect(buildCtx, rtCtx));
|
|
@@ -160,6 +175,8 @@ export async function collectSubAgentsWithFallback(
|
|
|
160
175
|
sessionId,
|
|
161
176
|
sharedFactory,
|
|
162
177
|
fallbackContext,
|
|
178
|
+
extraMiddleware,
|
|
179
|
+
resultCap,
|
|
163
180
|
);
|
|
164
181
|
const withPassthrough: AgentSpec = passthroughTools?.length
|
|
165
182
|
? { ...spec, passthroughTools }
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Command } from '@langchain/langgraph';
|
|
2
|
+
import { capToolResult, type ResultCapConfig } from './middlewares/result-cap';
|
|
1
3
|
import { tool } from '@langchain/core/tools';
|
|
2
4
|
import type { StructuredTool } from 'langchain';
|
|
3
5
|
import type {
|
|
@@ -35,6 +37,13 @@ export interface WrapPluginToolOptions {
|
|
|
35
37
|
* `invoke`/`stream` (the Node runtime does) overrides it.
|
|
36
38
|
*/
|
|
37
39
|
fallbackContext?: RunConfigContext;
|
|
40
|
+
/**
|
|
41
|
+
* The turn's result cap (result-cap.ts). Applied to the handler's return
|
|
42
|
+
* value HERE, before LangChain turns it into a ToolMessage, so the SSE
|
|
43
|
+
* `tool_call` frame, the model and the transcript all carry the same
|
|
44
|
+
* capped text. Graph commands (`load_capability`) pass through.
|
|
45
|
+
*/
|
|
46
|
+
resultCap?: ResultCapConfig;
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
/**
|
|
@@ -72,8 +81,14 @@ export function wrapPluginTool(
|
|
|
72
81
|
pluginTool: PluginTool,
|
|
73
82
|
options: WrapPluginToolOptions,
|
|
74
83
|
): StructuredTool {
|
|
75
|
-
const {
|
|
76
|
-
|
|
84
|
+
const {
|
|
85
|
+
ambient,
|
|
86
|
+
state,
|
|
87
|
+
pluginTitle,
|
|
88
|
+
sharedFactory,
|
|
89
|
+
fallbackContext,
|
|
90
|
+
resultCap,
|
|
91
|
+
} = options;
|
|
77
92
|
const description = pluginTitle
|
|
78
93
|
? `[${pluginTitle}] ${pluginTool.description}`
|
|
79
94
|
: pluginTool.description;
|
|
@@ -89,7 +104,13 @@ export function wrapPluginTool(
|
|
|
89
104
|
state,
|
|
90
105
|
sharedFactory,
|
|
91
106
|
);
|
|
92
|
-
|
|
107
|
+
const output = await pluginTool.handler(args, ctx);
|
|
108
|
+
if (!resultCap || output instanceof Command || output == null)
|
|
109
|
+
return output;
|
|
110
|
+
const text = typeof output === 'string' ? output : JSON.stringify(output);
|
|
111
|
+
if (typeof text !== 'string' || text.length <= resultCap.capChars)
|
|
112
|
+
return output;
|
|
113
|
+
return (await capToolResult(text, pluginTool.name, resultCap)).text;
|
|
93
114
|
},
|
|
94
115
|
{
|
|
95
116
|
name: pluginTool.name,
|
package/src/do/ambient.ts
CHANGED
|
@@ -141,6 +141,23 @@ export function createMatrixAdapter(
|
|
|
141
141
|
postEvent: (roomId, eventType, content, opts) =>
|
|
142
142
|
gateway.sendEvent(roomId, eventType, JSON.stringify(content), opts ?? {}),
|
|
143
143
|
botCredentials: () => gateway.botCredentials(),
|
|
144
|
+
roomInfo: (roomId) => gateway.groupChatRoomInfo(roomId),
|
|
145
|
+
channelMemory: {
|
|
146
|
+
recall: async (roomId, limit) =>
|
|
147
|
+
JSON.parse(await gateway.channelMemoryRecall(roomId, limit)),
|
|
148
|
+
search: async (roomId, query, limit) =>
|
|
149
|
+
JSON.parse(await gateway.channelMemorySearch(roomId, query, limit)),
|
|
150
|
+
pin: async (args) =>
|
|
151
|
+
JSON.parse(
|
|
152
|
+
await gateway.channelMemoryPin(
|
|
153
|
+
args.roomId,
|
|
154
|
+
args.fact,
|
|
155
|
+
args.pinnedByDid,
|
|
156
|
+
args.sourceEventId,
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
unpin: (roomId, factId) => gateway.channelMemoryUnpin(roomId, factId),
|
|
160
|
+
},
|
|
144
161
|
getRoomState: async (roomId): Promise<RoomStateSnapshot> => {
|
|
145
162
|
const raw = await gateway.getRoomState(roomId);
|
|
146
163
|
return { roomId, state: JSON.parse(raw) as unknown[] };
|