@tianmucreations/jeeves 0.2.1 → 0.3.1
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/LICENSE +37 -17
- package/README.md +82 -18
- package/bin/jeeves +8 -1
- package/dist/agent/auto-ids.js +66 -0
- package/dist/agent/auto.js +178 -0
- package/dist/agent/context.js +55 -13
- package/dist/agent/errors.js +83 -22
- package/dist/agent/expert-chat.js +33 -0
- package/dist/agent/housekeeping.js +55 -0
- package/dist/agent/loop.js +174 -12
- package/dist/agent/permissions.js +186 -3
- package/dist/agent/research-gate.js +267 -0
- package/dist/agent/review.js +135 -0
- package/dist/agent/spending.js +73 -0
- package/dist/agent/systemPrompt.js +112 -0
- package/dist/agent/trust.js +29 -0
- package/dist/app.js +31 -11
- package/dist/checkpoints/index.js +103 -0
- package/dist/checkpoints/store.js +239 -0
- package/dist/commands/address.js +5 -0
- package/dist/commands/clear.js +2 -0
- package/dist/commands/help.js +8 -4
- package/dist/commands/keys.js +1 -1
- package/dist/commands/verbose.js +1 -1
- package/dist/components/AddressPrompt.js +31 -0
- package/dist/components/Footer.js +74 -102
- package/dist/components/Input.js +115 -29
- package/dist/components/KeysManager.js +65 -20
- package/dist/components/ModelPicker.js +348 -75
- package/dist/components/ProjectPicker.js +4 -1
- package/dist/components/Transcript.js +29 -14
- package/dist/components/input-layout.js +92 -0
- package/dist/components/transcript-layout.js +27 -19
- package/dist/index.js +25 -7
- package/dist/ink/AlternateScreen.js +33 -16
- package/dist/ink/cursor.js +18 -0
- package/dist/ink/mouse.js +48 -0
- package/dist/keys/store.js +2 -1
- package/dist/models/registry.js +18 -2
- package/dist/platform/config.js +71 -7
- package/dist/providers/catalogue.js +293 -0
- package/dist/providers/direct-services.js +65 -0
- package/dist/providers/direct.js +145 -0
- package/dist/providers/index.js +123 -13
- package/dist/providers/models-snapshot.js +1037 -0
- package/dist/providers/ollama.js +21 -4
- package/dist/providers/openrouter.js +39 -4
- package/dist/providers/step-control.js +28 -0
- package/dist/providers/zai.js +31 -11
- package/dist/state/session.js +110 -36
- package/dist/state/today-spend.js +26 -0
- package/dist/tools/index.js +123 -11
- package/dist/tools/runBash.js +58 -11
- package/dist/tools/web/htmlToText.js +32 -0
- package/dist/tools/web/openrouterChat.js +31 -0
- package/dist/tools/web/research.js +191 -0
- package/package.json +33 -7
package/dist/agent/errors.js
CHANGED
|
@@ -1,41 +1,102 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { directService, serviceNameFor, CUSTOM_SERVICE_ID } from '../providers/direct-services.js';
|
|
2
|
+
import { getCustomService } from '../platform/config.js';
|
|
3
|
+
// The AI service's name as the user knows it, for messages that name it.
|
|
4
|
+
function serviceName(providerId) {
|
|
5
|
+
if (providerId === 'zai')
|
|
6
|
+
return 'Z.ai';
|
|
7
|
+
if (providerId === 'ollama')
|
|
8
|
+
return 'Ollama';
|
|
9
|
+
if (providerId === CUSTOM_SERVICE_ID) {
|
|
10
|
+
const saved = getCustomService();
|
|
11
|
+
return saved ? serviceNameFor(saved.baseURL) : 'The service';
|
|
12
|
+
}
|
|
13
|
+
const direct = providerId ? directService(providerId) : undefined;
|
|
14
|
+
if (direct)
|
|
15
|
+
return direct.label;
|
|
16
|
+
return 'OpenRouter';
|
|
17
|
+
}
|
|
18
|
+
// Turns technical failures into plain English (Phase 9 polish). Nothing technical
|
|
19
|
+
// reaches the screen: unrecognised failures get a plain sentence, and the raw text
|
|
20
|
+
// travels in detail for /verbose.
|
|
21
|
+
// The HTTP status of a failed request. The AI SDK puts it on the error itself, or on
|
|
22
|
+
// lastError once its retries give up; the message text alone often doesn't say it
|
|
23
|
+
// (a bad OpenRouter key reads just "User not found.", measured live).
|
|
24
|
+
export function statusOf(error) {
|
|
25
|
+
const candidates = [error, error?.lastError];
|
|
26
|
+
for (const candidate of candidates) {
|
|
27
|
+
const code = candidate?.statusCode;
|
|
28
|
+
if (typeof code === 'number')
|
|
29
|
+
return code;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
4
32
|
}
|
|
5
|
-
|
|
6
|
-
export function plainError(error) {
|
|
33
|
+
export function plainError(error, providerId) {
|
|
7
34
|
const raw = error instanceof Error ? error.message : String(error);
|
|
35
|
+
const status = statusOf(error);
|
|
8
36
|
const text = raw.toLowerCase();
|
|
9
|
-
|
|
10
|
-
|
|
37
|
+
const service = serviceName(providerId);
|
|
38
|
+
const make = (message, kind) => ({ message, kind, detail: raw });
|
|
39
|
+
if (/^no .*\bkey\b/.test(text)) {
|
|
40
|
+
return make("There's no key yet - type /keys to add one.", 'auth');
|
|
11
41
|
}
|
|
12
|
-
if (
|
|
13
|
-
|
|
42
|
+
if (status === 401 ||
|
|
43
|
+
status === 403 ||
|
|
44
|
+
text.includes('401') ||
|
|
45
|
+
text.includes('unauthorized') ||
|
|
46
|
+
text.includes('invalid api key') ||
|
|
47
|
+
// Google answers 400 "API key not valid"; OpenAI "Incorrect API key provided";
|
|
48
|
+
// Anthropic "invalid x-api-key".
|
|
49
|
+
text.includes('api key not valid') ||
|
|
50
|
+
text.includes('incorrect api key') ||
|
|
51
|
+
text.includes('invalid x-api-key') ||
|
|
52
|
+
text.includes('not authenticated') ||
|
|
53
|
+
text.includes('authentication failed') ||
|
|
54
|
+
text.includes('user not found')) {
|
|
55
|
+
return make(`${service} didn't accept the key - type /keys to check or replace it.`, 'auth');
|
|
14
56
|
}
|
|
15
|
-
|
|
16
|
-
|
|
57
|
+
// Z.ai's flat plan reports its time window as "Usage limit reached for 5 hour.
|
|
58
|
+
// Your limit will reset at 2026-09-17 13:03:01" (seen in a real session). The
|
|
59
|
+
// reset time is repeated exactly as Z.ai gave it - its time zone is not stated.
|
|
60
|
+
if (text.includes('usage limit')) {
|
|
61
|
+
const reset = raw.match(/reset at \d{4}-\d{2}-\d{2} (\d{2}:\d{2})/i);
|
|
62
|
+
const when = reset ? ` Z.ai says it resets at ${reset[1]}.` : '';
|
|
63
|
+
return {
|
|
64
|
+
...make(`Your ${service} plan has used up its allowance for now.${when} Type /model to use a different model meanwhile.`, 'payment'),
|
|
65
|
+
resetAt: reset ? reset[1] : '',
|
|
66
|
+
};
|
|
17
67
|
}
|
|
18
|
-
if (
|
|
19
|
-
|
|
68
|
+
if (status === 402 ||
|
|
69
|
+
text.includes('402') ||
|
|
70
|
+
text.includes('insufficient') ||
|
|
71
|
+
text.includes('out of credit') ||
|
|
72
|
+
text.includes('quota') ||
|
|
73
|
+
// Anthropic: "Your credit balance is too low to access the Anthropic API".
|
|
74
|
+
text.includes('credit balance')) {
|
|
75
|
+
const topUp = service === 'OpenRouter' ? 'top up at openrouter.ai/credits' : `add credit on the ${service} website`;
|
|
76
|
+
return make(`${service} credit ran out - ${topUp}, then ask again.`, 'payment');
|
|
77
|
+
}
|
|
78
|
+
if (status === 429 || text.includes('429') || text.includes('rate limit') || text.includes('rate_limit') || text.includes('too many requests')) {
|
|
79
|
+
return make(`${service} is asking us to slow down - wait a few seconds and ask again.`, 'rate-limit');
|
|
80
|
+
}
|
|
81
|
+
const name = error?.name;
|
|
82
|
+
if (name === 'TimeoutError' || text.includes('timed out') || text.includes('timeout')) {
|
|
83
|
+
return make(`${service} stopped responding partway through - please ask again.`, 'network');
|
|
20
84
|
}
|
|
21
85
|
if (text.includes('fetch failed') ||
|
|
22
86
|
text.includes('network') ||
|
|
23
87
|
text.includes('enotfound') ||
|
|
24
88
|
text.includes('econnrefused') ||
|
|
89
|
+
text.includes('econnreset') ||
|
|
25
90
|
text.includes('etimedout') ||
|
|
26
|
-
text.includes('timeout') ||
|
|
27
91
|
text.includes('eai_again') ||
|
|
28
92
|
text.includes('socket hang up')) {
|
|
29
|
-
return {
|
|
93
|
+
return make(`Couldn't reach ${service} - check the internet connection and ask again in a moment.`, 'network');
|
|
30
94
|
}
|
|
31
|
-
if (text.includes('
|
|
32
|
-
return
|
|
95
|
+
if ((status === 404 || text.includes('404') || text.includes('not found')) && text.includes('model')) {
|
|
96
|
+
return make("That model isn't available any more - type /model to pick another.", 'model');
|
|
33
97
|
}
|
|
34
98
|
if (text.includes('context') && (text.includes('length') || text.includes('too long'))) {
|
|
35
|
-
return
|
|
36
|
-
message: 'This conversation grew too long for the model - type /model to switch, or /clear to start fresh.',
|
|
37
|
-
kind: 'context',
|
|
38
|
-
};
|
|
99
|
+
return make('This conversation grew too long for the model - type /model to switch, or /clear to start fresh.', 'context');
|
|
39
100
|
}
|
|
40
|
-
return
|
|
101
|
+
return make("Something went wrong with that request and I don't recognise the reason - please ask again. Type /verbose to see the technical details next time.", 'other');
|
|
41
102
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { generateText } from 'ai';
|
|
2
|
+
import { session } from '../state/session.js';
|
|
3
|
+
import { getOpenRouterKey, serviceKey } from '../providers/index.js';
|
|
4
|
+
import { openrouterChat } from '../tools/web/openrouterChat.js';
|
|
5
|
+
import { isDirectService } from '../providers/direct-services.js';
|
|
6
|
+
import { modelFactory } from '../providers/direct.js';
|
|
7
|
+
import { estimateCost, priceOf } from '../providers/catalogue.js';
|
|
8
|
+
import { reportSpend } from './spending.js';
|
|
9
|
+
export const expertChat = async (model, messages, maxTokens) => {
|
|
10
|
+
const providerId = session.providerId;
|
|
11
|
+
if (providerId === 'openrouter') {
|
|
12
|
+
const key = getOpenRouterKey();
|
|
13
|
+
if (!key)
|
|
14
|
+
throw new Error('the expert needs an OpenRouter key');
|
|
15
|
+
return (await openrouterChat(key, { model, messages, max_tokens: maxTokens })).text;
|
|
16
|
+
}
|
|
17
|
+
if (isDirectService(providerId)) {
|
|
18
|
+
const key = serviceKey(providerId);
|
|
19
|
+
if (!key)
|
|
20
|
+
throw new Error('the expert needs a key for this service');
|
|
21
|
+
const result = await generateText({
|
|
22
|
+
model: modelFactory(providerId, key)(model),
|
|
23
|
+
instructions: messages.filter((m) => m.role === 'system').map((m) => m.content).join('\n\n') || undefined,
|
|
24
|
+
messages: messages.filter((m) => m.role === 'user').map((m) => ({ role: 'user', content: m.content })),
|
|
25
|
+
// Thinking models spend part of the allowance before answering, so it is larger here.
|
|
26
|
+
maxOutputTokens: maxTokens * 4,
|
|
27
|
+
abortSignal: AbortSignal.timeout(120_000),
|
|
28
|
+
});
|
|
29
|
+
reportSpend(estimateCost(priceOf(providerId, model), result.usage), true);
|
|
30
|
+
return result.text.trim();
|
|
31
|
+
}
|
|
32
|
+
throw new Error('no expert is available for this service');
|
|
33
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Quiet housekeeping that keeps the conversation small, because every message
|
|
2
|
+
// re-sends the whole conversation. Modelled on Anthropic's published context
|
|
3
|
+
// editing (platform.claude.com/docs/en/build-with-claude/context-editing): old
|
|
4
|
+
// tool results are cleared first - they are the bulk, and a file can simply be
|
|
5
|
+
// read again - keeping the most recent 3, and only when enough is freed to be
|
|
6
|
+
// worth losing the prompt-cache discount for one message. The trigger and minimum
|
|
7
|
+
// are the figures from Anthropic's own worked example (30,000 and 5,000 tokens);
|
|
8
|
+
// their default trigger is 100,000, which is where summarising starts.
|
|
9
|
+
export const CLEAR_TRIGGER_TOKENS = 30_000;
|
|
10
|
+
export const CLEAR_AT_LEAST_TOKENS = 5_000;
|
|
11
|
+
export const KEEP_RECENT_TOOL_RESULTS = 3;
|
|
12
|
+
export const SUMMARY_TRIGGER_TOKENS = 100_000;
|
|
13
|
+
export const CLEARED_PLACEHOLDER = '[Earlier output removed to keep the conversation small. Run the tool again if it is needed.]';
|
|
14
|
+
// The same rough measure used elsewhere in the app: about 4 characters per token.
|
|
15
|
+
export function estimateTokens(value) {
|
|
16
|
+
return Math.ceil(JSON.stringify(value).length / 4);
|
|
17
|
+
}
|
|
18
|
+
export function clearOldToolResults(messages) {
|
|
19
|
+
if (estimateTokens(messages) < CLEAR_TRIGGER_TOKENS)
|
|
20
|
+
return { messages, freedTokens: 0 };
|
|
21
|
+
const positions = [];
|
|
22
|
+
messages.forEach((message, messageIndex) => {
|
|
23
|
+
if (message.role !== 'tool' || !Array.isArray(message.content))
|
|
24
|
+
return;
|
|
25
|
+
message.content.forEach((part, partIndex) => {
|
|
26
|
+
if (part.type === 'tool-result')
|
|
27
|
+
positions.push({ message: messageIndex, part: partIndex });
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
const older = positions.slice(0, Math.max(0, positions.length - KEEP_RECENT_TOOL_RESULTS));
|
|
31
|
+
let freed = 0;
|
|
32
|
+
const copy = messages.map((message) => message.role === 'tool' && Array.isArray(message.content) ? { ...message, content: [...message.content] } : message);
|
|
33
|
+
for (const { message, part } of older) {
|
|
34
|
+
const content = copy[message].content;
|
|
35
|
+
const result = content[part];
|
|
36
|
+
if (result.output?.type === 'text' && result.output.value === CLEARED_PLACEHOLDER)
|
|
37
|
+
continue;
|
|
38
|
+
const before = estimateTokens(result.output);
|
|
39
|
+
const replacement = { type: 'text', value: CLEARED_PLACEHOLDER };
|
|
40
|
+
freed += before - estimateTokens(replacement);
|
|
41
|
+
content[part] = { ...result, output: replacement };
|
|
42
|
+
}
|
|
43
|
+
if (freed < CLEAR_AT_LEAST_TOKENS)
|
|
44
|
+
return { messages, freedTokens: 0 };
|
|
45
|
+
return { messages: copy, freedTokens: freed };
|
|
46
|
+
}
|
|
47
|
+
// Anthropic's structured summary (the five parts of their compaction prompt), so a
|
|
48
|
+
// summary keeps what matters for carrying on with the job.
|
|
49
|
+
export const SUMMARY_INSTRUCTIONS = `Summarise the conversation so far so the work can continue from the summary alone. Use exactly these headings:
|
|
50
|
+
1. Task overview - what the person asked for and what counts as done.
|
|
51
|
+
2. Current state - what has been completed, which files were changed or created.
|
|
52
|
+
3. Important discoveries - facts checked, decisions made, errors met and how they were resolved, approaches that failed.
|
|
53
|
+
4. Next steps - what remains, in order, and anything blocking it.
|
|
54
|
+
5. Context to preserve - the person's preferences, how they like to be addressed, promises made.
|
|
55
|
+
Be specific: keep exact file names, figures, and quoted facts. Reply with only the summary.`;
|
package/dist/agent/loop.js
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import { session } from '../state/session.js';
|
|
2
2
|
import { getActiveProvider, refreshCredit } from '../providers/index.js';
|
|
3
3
|
import { getTools } from '../tools/index.js';
|
|
4
|
-
import { buildTurnMessages } from './context.js';
|
|
4
|
+
import { buildTurnMessages, getSystemPrompt, contextLimitFor, summaryDue, summariseHistory } from './context.js';
|
|
5
5
|
import { plainError } from './errors.js';
|
|
6
6
|
import { toggleVerbose } from '../commands/verbose.js';
|
|
7
7
|
import { openModelPicker } from '../commands/model.js';
|
|
8
8
|
import { clearConversation } from '../commands/clear.js';
|
|
9
|
+
import { openAddressPrompt } from '../commands/address.js';
|
|
9
10
|
import { isToolCapable } from '../models/filter.js';
|
|
11
|
+
import { autoCatalogue } from './auto.js';
|
|
12
|
+
import { isAuto, workingModelId, workerModel, expertModel, topModel, AUTO_NOTE, shouldTakeOver, createAskExpertTool, newAutoTurnState, topModelPriceRatio, topModelQuestion, REVIEW_FINISHED_JOBS } from './auto.js';
|
|
13
|
+
import { jobNeedsReview, reviewJob, fixRequest, startReproducing, stopReproducing, UNCHECKED_NOTICE } from './review.js';
|
|
14
|
+
import { requestApproval } from './permissions.js';
|
|
15
|
+
import { clearOldToolResults } from './housekeeping.js';
|
|
16
|
+
import { startJob, endJob, reportStepCost, withinLimits } from './spending.js';
|
|
17
|
+
import { getAddress } from '../platform/config.js';
|
|
18
|
+
import { startTurnCheckpoints, undoLastChange } from '../checkpoints/index.js';
|
|
19
|
+
import { noteSkipRequest } from './research-gate.js';
|
|
20
|
+
import { untrustProject } from './trust.js';
|
|
21
|
+
// Added to the rulebook when the chosen model cannot use tools, so a task request
|
|
22
|
+
// gets a plain answer instead of a pretend attempt.
|
|
23
|
+
export const CHAT_ONLY_NOTE = `
|
|
24
|
+
|
|
25
|
+
Chat-Only Model
|
|
26
|
+
|
|
27
|
+
The model currently selected can only chat. For now you have no tools: you cannot read files, write files, list folders, or run commands, whatever the sections above say. If you are asked to do something that needs them, say plainly that the model in use can only chat, and suggest typing /model to choose one that can do tasks. Never pretend to have done it.`;
|
|
10
28
|
const DISCONNECTING = new Set(['auth', 'network', 'payment']);
|
|
11
29
|
export async function runTurn(input) {
|
|
12
30
|
if (input.startsWith('/') && input.length > 1 && !input.startsWith('/ ')) {
|
|
@@ -22,6 +40,32 @@ export async function runTurn(input) {
|
|
|
22
40
|
else if (input === '/verbose') {
|
|
23
41
|
session.addNotice(toggleVerbose());
|
|
24
42
|
}
|
|
43
|
+
else if (input === '/address') {
|
|
44
|
+
openAddressPrompt();
|
|
45
|
+
}
|
|
46
|
+
else if (input === '/ask') {
|
|
47
|
+
session.addNotice(untrustProject()
|
|
48
|
+
? "I'll ask before every change in this project folder again."
|
|
49
|
+
: 'I already ask before every change in this project folder.');
|
|
50
|
+
}
|
|
51
|
+
else if (input === '/undo') {
|
|
52
|
+
if (session.status === 'working') {
|
|
53
|
+
session.addNotice('Undo works between tasks - wait for this one to finish, then type /undo.');
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
try {
|
|
57
|
+
const outcome = await undoLastChange();
|
|
58
|
+
session.addNotice(outcome.message);
|
|
59
|
+
if (outcome.historyNote)
|
|
60
|
+
session.pendingContextNote = outcome.historyNote;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
session.addError("Undo didn't work this time - nothing was changed. Please try /undo again.");
|
|
64
|
+
if (session.verbose)
|
|
65
|
+
session.addNotice(`Technical details: ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
25
69
|
else if (input === '/clear') {
|
|
26
70
|
clearConversation();
|
|
27
71
|
}
|
|
@@ -29,24 +73,87 @@ export async function runTurn(input) {
|
|
|
29
73
|
session.requestExit();
|
|
30
74
|
}
|
|
31
75
|
else {
|
|
32
|
-
session.addNotice('
|
|
76
|
+
session.addNotice("I don't know that command - type /help to see them all.");
|
|
33
77
|
}
|
|
34
78
|
return;
|
|
35
79
|
}
|
|
36
80
|
session.addUser(input);
|
|
81
|
+
// "Skip the research" must come from the person, so it is read from their own words.
|
|
82
|
+
const skipped = noteSkipRequest(input);
|
|
83
|
+
if (skipped)
|
|
84
|
+
session.addNotice(skipped);
|
|
85
|
+
startTurnCheckpoints(input);
|
|
86
|
+
startJob();
|
|
87
|
+
// Nothing is sent once today's limit is reached, unless the person agrees.
|
|
88
|
+
if (!(await withinLimits())) {
|
|
89
|
+
endJob();
|
|
90
|
+
session.addNotice('Stopped - nothing was sent.');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Quiet housekeeping: old tool output is cleared, and a long conversation summarised.
|
|
94
|
+
const cleared = clearOldToolResults(session.history);
|
|
95
|
+
if (cleared.freedTokens > 0)
|
|
96
|
+
session.setHistory(cleared.messages);
|
|
97
|
+
const modelId = workingModelId(session.model);
|
|
98
|
+
if (summaryDue(session.estimateContextTokens(), contextLimitFor(modelId, session.models))) {
|
|
99
|
+
await summariseHistory();
|
|
100
|
+
}
|
|
101
|
+
const auto = isAuto(session.model);
|
|
102
|
+
const autoState = newAutoTurnState();
|
|
103
|
+
session.setActiveModel(auto ? workerModel() : null);
|
|
104
|
+
const stop = new AbortController();
|
|
105
|
+
let countedSteps = 0;
|
|
37
106
|
session.beginTurn();
|
|
38
107
|
session.setStatus('working');
|
|
108
|
+
const turnStart = session.transcript.length;
|
|
39
109
|
let assistantId = null;
|
|
40
110
|
try {
|
|
41
111
|
const provider = getActiveProvider();
|
|
42
|
-
|
|
112
|
+
// A note from /undo travels with the next message, so the model knows files changed back.
|
|
113
|
+
const note_ = session.pendingContextNote;
|
|
114
|
+
session.pendingContextNote = null;
|
|
115
|
+
const messages = buildTurnMessages(session.history, note_ ? `${note_}\n\n${input}` : input);
|
|
43
116
|
// Models without tool support get a tool-free chat mode automatically (spec 4.2).
|
|
44
|
-
const currentModel = session.models.find((model) => model.id ===
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
117
|
+
const currentModel = session.models.find((model) => model.id === modelId);
|
|
118
|
+
const toolCapable = !currentModel || isToolCapable(currentModel);
|
|
119
|
+
const tools = toolCapable ? { ...getTools(), ...(auto ? { askExpert: createAskExpertTool(autoState) } : {}) } : {};
|
|
120
|
+
const note = !toolCapable ? CHAT_ONLY_NOTE : auto ? AUTO_NOTE.replaceAll('{{ADDRESS}}', getAddress() ?? 'Sir') : '';
|
|
121
|
+
const streamOptions = {
|
|
122
|
+
modelId,
|
|
48
123
|
messages,
|
|
49
124
|
tools,
|
|
125
|
+
instructions: getSystemPrompt() + note,
|
|
126
|
+
abortSignal: stop.signal,
|
|
127
|
+
beforeStep: async ({ stepFailures, stepCosts, messages: stepMessages }) => {
|
|
128
|
+
for (const cost of stepCosts.slice(countedSteps))
|
|
129
|
+
reportStepCost(cost);
|
|
130
|
+
countedSteps = stepCosts.length;
|
|
131
|
+
if (!(await withinLimits())) {
|
|
132
|
+
stop.abort();
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
135
|
+
// In Auto mode the expert takes over the rest of a job the worker keeps failing.
|
|
136
|
+
// If the expert keeps failing too, Jeeves asks before trying the strongest model.
|
|
137
|
+
let stepModel;
|
|
138
|
+
const expert = expertModel();
|
|
139
|
+
const strongest = topModel();
|
|
140
|
+
if (auto && expert && !autoState.expertTookOver && shouldTakeOver(stepFailures))
|
|
141
|
+
autoState.expertTookOver = true;
|
|
142
|
+
// (The expert can also take over by saying so when consulted.)
|
|
143
|
+
if (autoState.expertTookOver && autoState.takeoverStep < 0)
|
|
144
|
+
autoState.takeoverStep = stepFailures.length;
|
|
145
|
+
if (auto && strongest && autoState.expertTookOver && !autoState.askedAboutTop && shouldTakeOver(stepFailures.slice(autoState.takeoverStep))) {
|
|
146
|
+
autoState.askedAboutTop = true;
|
|
147
|
+
session.addNotice(topModelQuestion(getAddress() ?? 'Sir', topModelPriceRatio(autoCatalogue())));
|
|
148
|
+
autoState.onTopModel = await requestApproval();
|
|
149
|
+
}
|
|
150
|
+
if (auto && autoState.expertTookOver && expert) {
|
|
151
|
+
stepModel = autoState.onTopModel && strongest ? strongest : expert;
|
|
152
|
+
session.setActiveModel(stepModel);
|
|
153
|
+
}
|
|
154
|
+
const tidied = clearOldToolResults(stepMessages);
|
|
155
|
+
return { modelId: stepModel, messages: tidied.freedTokens > 0 ? tidied.messages : undefined };
|
|
156
|
+
},
|
|
50
157
|
onToken: (token) => {
|
|
51
158
|
if (assistantId === null)
|
|
52
159
|
assistantId = session.startAssistant();
|
|
@@ -57,28 +164,83 @@ export async function runTurn(input) {
|
|
|
57
164
|
session.appendReasoning(delta);
|
|
58
165
|
},
|
|
59
166
|
onToolCall: () => {
|
|
60
|
-
// Hide pre-tool chatter so only the final answer stays visible (spec 2.3).
|
|
61
|
-
|
|
167
|
+
// Hide pre-tool chatter so only the final answer stays visible (spec 2.3). The
|
|
168
|
+
// entry is removed, not just emptied, so the final answer appears below the
|
|
169
|
+
// actions it reports on rather than above them.
|
|
170
|
+
if (assistantId !== null) {
|
|
62
171
|
session.setAssistantText(assistantId, '');
|
|
172
|
+
session.finishAssistant(assistantId);
|
|
173
|
+
assistantId = null;
|
|
174
|
+
}
|
|
63
175
|
session.closeReasoningEntry();
|
|
64
176
|
},
|
|
65
|
-
}
|
|
177
|
+
};
|
|
178
|
+
let uncheckedNotice = false;
|
|
179
|
+
let result = await provider.stream(streamOptions);
|
|
180
|
+
let allMessages = [...messages, ...result.messages];
|
|
181
|
+
// Auto's double-check, once, when this job changed a program or wrote a document
|
|
182
|
+
// (where the cheap worker's mistakes were measured - see review.ts).
|
|
183
|
+
if (auto && REVIEW_FINISHED_JOBS && jobNeedsReview(session.transcript.slice(turnStart)) && !stop.signal.aborted) {
|
|
184
|
+
for (const cost of (result.stepCosts ?? []).slice(countedSteps))
|
|
185
|
+
reportStepCost(cost);
|
|
186
|
+
countedSteps = result.stepCosts?.length ?? countedSteps;
|
|
187
|
+
const review = await reviewJob(allMessages);
|
|
188
|
+
if (review.kind === 'unavailable') {
|
|
189
|
+
uncheckedNotice = true;
|
|
190
|
+
}
|
|
191
|
+
else if (review.kind === 'problems') {
|
|
192
|
+
countedSteps = 0;
|
|
193
|
+
if (assistantId !== null)
|
|
194
|
+
session.setAssistantText(assistantId, '');
|
|
195
|
+
const fixMessages = [...allMessages, { role: 'user', content: fixRequest(review.problems) }];
|
|
196
|
+
// Changing files waits until the worker has reproduced a problem.
|
|
197
|
+
startReproducing();
|
|
198
|
+
try {
|
|
199
|
+
result = await provider.stream({ ...streamOptions, messages: fixMessages });
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
stopReproducing();
|
|
203
|
+
}
|
|
204
|
+
allMessages = [...fixMessages, ...result.messages];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
66
207
|
if (assistantId === null)
|
|
67
208
|
assistantId = session.startAssistant();
|
|
68
209
|
session.setAssistantText(assistantId, result.text);
|
|
69
210
|
session.finishAssistant(assistantId);
|
|
70
|
-
|
|
211
|
+
if (uncheckedNotice)
|
|
212
|
+
session.addNotice(UNCHECKED_NOTICE);
|
|
213
|
+
session.setHistory(allMessages);
|
|
71
214
|
session.setLastReasoning(result.reasoning);
|
|
72
215
|
session.addUsage(result.usage.input, result.usage.output, result.cost, result.usage.cached ?? 0);
|
|
73
216
|
session.setRateLimit(result.rateLimit);
|
|
74
217
|
void refreshCredit();
|
|
218
|
+
for (const cost of (result.stepCosts ?? []).slice(countedSteps))
|
|
219
|
+
reportStepCost(cost);
|
|
220
|
+
session.setPlanResetAt(null);
|
|
221
|
+
session.setActiveModel(auto ? workerModel() : null);
|
|
222
|
+
endJob();
|
|
75
223
|
session.setStatus('idle');
|
|
76
224
|
}
|
|
77
225
|
catch (error) {
|
|
78
|
-
|
|
226
|
+
endJob();
|
|
227
|
+
session.setActiveModel(auto ? workerModel() : null);
|
|
228
|
+
if (stop.signal.aborted) {
|
|
229
|
+
if (assistantId !== null)
|
|
230
|
+
session.finishAssistant(assistantId);
|
|
231
|
+
session.addNotice('Stopped, as you asked - nothing more will be spent on this.');
|
|
232
|
+
session.setStatus('idle');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const plain = plainError(error, session.providerId);
|
|
79
236
|
if (assistantId !== null)
|
|
80
237
|
session.finishAssistant(assistantId);
|
|
81
238
|
session.addError(plain.message);
|
|
239
|
+
if (plain.resetAt !== undefined)
|
|
240
|
+
session.setPlanResetAt(plain.resetAt);
|
|
241
|
+
// The technical text stays off screen unless /verbose is on.
|
|
242
|
+
if (session.verbose && plain.detail)
|
|
243
|
+
session.addNotice(`Technical details: ${plain.detail}`);
|
|
82
244
|
session.setStatus(DISCONNECTING.has(plain.kind) ? 'disconnected' : 'idle');
|
|
83
245
|
}
|
|
84
246
|
}
|