Skip to content

Commit a69e6e1

Browse files
ndemiancclaude
andcommitted
Editor agent: refresh the LevelCode token on a mid-run 401 and retry
Long/looping agent runs 401'd (Signature has expired) — only the chat path refreshed the access token on 401; the agent loop was called raw. Add isAgentAuthError + wrap the turn call: on a 401 before any content has streamed, refresh once (via a ctx.refreshAuth hook wired in extension.js), update ctx.apiKey, and retry the turn once. Mid-stream/second failures propagate; BYOK / refresh-failed rethrow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f9e47fc commit a69e6e1

2 files changed

Lines changed: 34 additions & 2 deletions

File tree

‎extensions/levelcode-ai/agent.js‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,13 @@ async function runTool(tu, ctx) {
393393
* providerId/baseURL select the model provider (Anthropic native, or any OpenAI-shaped one via the
394394
* tool-use translation layer); applyEdit applies file edits (apply-then-review); approve gates run_command only.
395395
*/
396+
// A gateway 401 (expired LevelCode access token) surfaces as "<provider> API 401: …" or the raw
397+
// "Signature has expired". Used to trigger ONE token-refresh + retry inside the agent loop (mirrors
398+
// the chat path's refresh-on-401 — see extension.js isAuthError).
399+
function isAgentAuthError(e) {
400+
return /\bAPI 401\b|signature has expired/i.test(String((e && e.message) || e));
401+
}
402+
396403
async function runAgent(ctx) {
397404
const root = workspaceRoot();
398405
if (!root) { ctx.post({ type: 'agentError', message: 'Open a folder first — the agent works on your workspace.' }); ctx.post({ type: 'agentDone', reason: 'error' }); return; }
@@ -481,7 +488,7 @@ async function runAgent(ctx) {
481488
dbg('turn.request', { step, transcriptMsgs: messages.length });
482489
let streamed = false;
483490
let textChars = 0;
484-
const turn = await providers.streamAgentTurn({
491+
const turnOpts = {
485492
providerId: ctx.providerId, baseURL: ctx.baseURL,
486493
apiKey: ctx.apiKey, model: ctx.model, maxTokens: perTurnMax, system: system,
487494
messages, tools: TOOLS, signal: ctx.signal,
@@ -493,7 +500,25 @@ async function runAgent(ctx) {
493500
: name === 'run_command' ? 'preparing command…' : name === 'update_plan' ? 'planning…' : 'running ' + name + '…';
494501
ctx.post({ type: 'agentStatus', text: verb });
495502
}
496-
});
503+
};
504+
let turn;
505+
try {
506+
turn = await providers.streamAgentTurn(turnOpts);
507+
} catch (e) {
508+
// LevelCode Cloud access token expired mid-run → refresh once and retry THIS turn. A 401 is
509+
// rejected upfront, so we only retry when nothing has streamed yet (a mid-stream failure
510+
// would otherwise duplicate output). BYOK / no refresh hook / refresh returned null → rethrow.
511+
if (!streamed && isAgentAuthError(e) && typeof ctx.refreshAuth === 'function') {
512+
const fresh = await ctx.refreshAuth();
513+
if (!fresh) { throw e; }
514+
dbg('turn.authRetry', { step });
515+
ctx.apiKey = fresh;
516+
turnOpts.apiKey = fresh;
517+
turn = await providers.streamAgentTurn(turnOpts);
518+
} else {
519+
throw e;
520+
}
521+
}
497522
if (streamed) { ctx.post({ type: 'agentTurnEnd' }); }
498523
dbg('turn.response', { step, stop: turn.stop_reason, blocks: turn.content.length, textChars, tools: turn.content.filter((c) => c.type === 'tool_use').map((c) => c.name + (turn.malformed && turn.malformed.has(c.id) ? '!malformed' : '')) });
499524
// Report real context usage (input_tokens = how full the transcript is) so the UI can meter + warn.

‎extensions/levelcode-ai/extension.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,13 @@ async function agentFlow(text) {
865865
post, dbg,
866866
approve: requestApproval, // run_command only
867867
ask: requestQuestions, // ask_user — clickable clarifying questions
868+
// Gateway-mode token refresh for the agent loop: on a mid-run 401 (expired access token)
869+
// agent.js calls this to renew silently + retry the turn. Returns the fresh token, or null
870+
// (BYOK / signed out / refresh failed → the run surfaces the auth error as before).
871+
refreshAuth: async () => {
872+
if (!req.gateway) { return null; }
873+
return (await refreshGatewayToken()) ? await ctx.secrets.get(ACCOUNT_TOKEN_KEY) : null;
874+
},
868875
skills: skillsObj, // M6.5: implicit skills (name+desc menu in SYSTEM + use_skill resolver)
869876
contextLimit: contextLimitFor(req.providerId, capsModel(req.model)), // Auto → flagship window; the model SENT stays req.model
870877
commandStops: commandStops, // runId → stop() (process-group kill); used by Stop button / ■

0 commit comments

Comments
 (0)