Repository navigation
fix(minimax-bot): bind a new Bot session to an Agent the host can open #56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -113,6 +113,14 @@ function errorText(error) { | |
| return String(error); | ||
| } | ||
|
|
||
| /** | ||
| * The Host could not bind a session to an Agent, so the turn never reached the model. A busy | ||
| * session is reported as BUSY instead, and a rejected Agent name as INVALID_ARGUMENTS. | ||
| */ | ||
| function isBindFailure(error) { | ||
| return errorCode(error) === 'AGENT_UNAVAILABLE'; | ||
| } | ||
|
|
||
| export class Engine { | ||
| /** | ||
| * @param {{ | ||
|
|
@@ -132,6 +140,10 @@ export class Engine { | |
| this.retryDelays = deps.retryDelaysMs ?? TURN_RETRY_DELAYS_MS; | ||
| /** @type {{ at: number, value: Promise<any> } | undefined} */ | ||
| this.catalogCache = undefined; | ||
| /** The Agent the Host could bind a new session to, once the default one proved unbindable. */ | ||
| this.boundAgent = undefined; | ||
| /** @type {Promise<{ name: string, displayName: string, primary: boolean }[]> | undefined} */ | ||
| this.agentOptions = undefined; | ||
| /** @type {Map<string, { controller: AbortController, stoppedBy?: string }>} helper id -> run */ | ||
| this.helperRuns = new Map(); | ||
| this.helperActive = 0; | ||
|
|
@@ -790,7 +802,7 @@ export class Engine { | |
| if (signal.aborted || this.disposed) return undefined; | ||
| let result; | ||
| try { | ||
| result = await agent.run({ text, sessionId, ...(model ? { model } : {}) }, { signal, onEvent }); | ||
| result = await this.runTurn({ text, sessionId, model, signal, onEvent }); | ||
| } catch (error) { | ||
| // The Host rejects a model it no longer offers before the turn starts; say which setting to fix. | ||
| if (model && errorCode(error) === 'INVALID_ARGUMENTS') { | ||
|
|
@@ -813,6 +825,91 @@ export class Engine { | |
| return typeof this.agent?.models === 'function'; | ||
| } | ||
|
|
||
| /** True when the Host lets a run name its Agent (`agent.agents()` / `run({ agent })`). */ | ||
| supportsAgents() { | ||
| return typeof this.agent?.agents === 'function'; | ||
| } | ||
|
|
||
| /** | ||
| * The Agents the Host offers, primary first. | ||
| * | ||
| * A new Mini App session binds to the primary Agent unless the caller names one. The Host | ||
| * offers the primary under its canonical name and rejects the legacy `main` alias, so a | ||
| * runtime that still stores the primary under that alias cannot bind it: the session never | ||
| * opens and every turn fails with AGENT_UNAVAILABLE before the model is reached. Naming an | ||
| * Agent the Host can bind keeps the Bot team working there. | ||
| * @returns {Promise<{ name: string, displayName: string, primary: boolean }[]>} | ||
| */ | ||
| async agentCandidates() { | ||
| if (!this.supportsAgents()) return []; | ||
| if (!this.agentOptions) { | ||
| this.agentOptions = Promise.resolve(/** @type {any} */ (this.agent).agents()) | ||
| .then((value) => (value?.agents ?? []) | ||
| .map((entry) => ({ | ||
| name: String(entry?.name ?? ''), | ||
| displayName: String(entry?.displayName ?? ''), | ||
| primary: Boolean(entry?.primary), | ||
| })) | ||
| .filter((entry) => entry.name)) | ||
| .catch((error) => { | ||
| // A failed lookup must not be cached for the rest of the process. | ||
| this.agentOptions = undefined; | ||
| throw error; | ||
| }); | ||
| } | ||
| const agents = await this.agentOptions; | ||
| return [...agents].sort((left, right) => Number(right.primary) - Number(left.primary)); | ||
| } | ||
|
|
||
| /** | ||
| * Runs a turn, naming the Agent when the Host cannot bind its default one. | ||
| * | ||
| * A new session binds to the primary Agent. Naming one is only allowed on the first run of a | ||
| * session, and a continued session keeps the Agent it was created with. When the default | ||
| * Agent cannot be bound, the Agents the Host offers are tried in turn, primary first, and the | ||
| * one that works is used for the sessions that follow. | ||
| */ | ||
| async runTurn({ text, sessionId, model, signal, onEvent }) { | ||
| const run = (agent) => /** @type {MiniAppAgentClient} */ (this.agent).run( | ||
| { | ||
| text, | ||
| ...(sessionId === undefined ? {} : { sessionId }), | ||
| ...(agent ? { agent } : {}), | ||
| ...(model ? { model } : {}), | ||
| }, | ||
| { signal, onEvent }, | ||
| ); | ||
| // A continued session keeps its Agent; naming one again is rejected by the Host. | ||
| if (sessionId) return run(undefined); | ||
| if (this.boundAgent) { | ||
| try { | ||
| return await run(this.boundAgent); | ||
| } catch (error) { | ||
| if (!isBindFailure(error)) throw error; | ||
| // The Agent is gone or unbindable now: look for another one. | ||
| this.boundAgent = undefined; | ||
| } | ||
| } | ||
| try { | ||
| return await run(undefined); | ||
| } catch (error) { | ||
| if (!isBindFailure(error)) throw error; | ||
| const candidates = await this.agentCandidates(); | ||
| for (const candidate of candidates) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a fallback stays: this tries every Agent in the list, which can include user-created ones. Could we only fall back to the primary Agent, or surface the problem to the user instead? |
||
| try { | ||
| const result = await run(candidate.name); | ||
| this.boundAgent = candidate.name; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Related: whichever Agent gets picked here is then kept for all later new sessions, with only a log line. Another reason to limit this to the primary Agent or tell the user. |
||
| this.logger?.warn?.('minimax_bot.agent_bound', { agent: candidate.name, offered: candidates.map((entry) => entry.name) }); | ||
| return result; | ||
| } catch (candidateError) { | ||
| if (!isBindFailure(candidateError)) throw candidateError; | ||
| } | ||
| } | ||
| this.logger?.warn?.('minimax_bot.agent_unbound', { offered: candidates.map((entry) => entry.name) }); | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * The Host's model catalog and default, cached briefly so a busy group round does not ask for | ||
| * it on every turn. `undefined` on a Host without per-run models. | ||
|
|
@@ -1522,7 +1619,7 @@ export class Engine { | |
| }; | ||
| const text = t('helper.brief', { bot: bot?.name ?? 'Bot', role: bot?.role || '-', task: helper.task }); | ||
| try { | ||
| result = await /** @type {any} */ (this.agent).run({ text, ...(model ? { model } : {}) }, { signal: controller.signal, onEvent }); | ||
| result = await this.runTurn({ text, sessionId: undefined, model, signal: controller.signal, onEvent }); | ||
| } catch (error) { | ||
| if (model && errorCode(error) === 'INVALID_ARGUMENTS') { | ||
| throw Object.assign(new Error(errorText(error)), { code: 'MODEL_UNAVAILABLE' }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| { | ||
| "name": "minimax-bot", | ||
| "version": "0.7.7", | ||
| "version": "0.7.8", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #49 also bumps to 0.7.8 — whichever merges second should go to 0.7.9. |
||
| "private": true, | ||
| "type": "module", | ||
| "mcode": { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { test } from 'node:test'; | ||
| import { mkdtemp, rm } from 'node:fs/promises'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { Store } from '../miniapp/node/store.mjs'; | ||
| import { MemoryStore } from '../miniapp/node/memory.mjs'; | ||
| import { Engine } from '../miniapp/node/engine.mjs'; | ||
| import { setLocale } from '../miniapp/node/i18n.mjs'; | ||
|
|
||
| async function fixture(t) { | ||
| const dir = await mkdtemp(join(tmpdir(), 'bot-agent-binding-')); | ||
| const store = new Store(dir); | ||
| const memory = new MemoryStore(dir); | ||
| await store.init(); | ||
| await memory.init(); | ||
| setLocale('en'); | ||
| const warnings = []; | ||
| const engine = new Engine({ | ||
| store, | ||
| memory, | ||
| logger: { warn(message, detail) { warnings.push({ message, detail }); } }, | ||
| broadcast() {}, | ||
| retryDelaysMs: [], | ||
| }); | ||
| t.after(async () => { | ||
| engine.dispose(); | ||
| await store.writeChain; | ||
| await rm(dir, { recursive: true, force: true }); | ||
| }); | ||
| return { dir, store, memory, engine, warnings }; | ||
| } | ||
|
|
||
| /** The Host could not bind a session to the Agent it was asked for. */ | ||
| function bindFailure() { | ||
| return Object.assign(new Error('Mini App Agent session is unavailable'), { code: 'AGENT_UNAVAILABLE', retryable: false }); | ||
| } | ||
|
|
||
| function turnStarted(options, sessionId) { | ||
| options.onEvent({ type: 'turn.started', sessionId, turnId: `turn-${sessionId}` }); | ||
| return { sessionId, turnId: `turn-${sessionId}` }; | ||
| } | ||
|
|
||
| test('a new session names an Agent the Host can bind when the default one cannot be bound', async (t) => { | ||
| const { engine, store, warnings } = await fixture(t); | ||
| const bot = { id: 'bot_a', name: 'Alice', role: 'Writer' }; | ||
| store.state.bots = [bot]; | ||
| const calls = []; | ||
| engine.agent = { | ||
| async agents() { | ||
| return { agents: [ | ||
| { name: 'mavis', displayName: 'Mavis', primary: true }, | ||
| { name: 'general', displayName: 'General', primary: false }, | ||
| ] }; | ||
| }, | ||
| async run(input, options) { | ||
| calls.push(input); | ||
| // The Host cannot bind the primary: it stores it under the legacy `main` alias and | ||
| // rejects that alias, so the session never opens. | ||
| if (!input.agent || input.agent === 'mavis') throw bindFailure(); | ||
| return { ...turnStarted(options, 'session-general'), status: 'finished', text: 'Done' }; | ||
| }, | ||
| }; | ||
| const result = await engine.runBotTurn({ bot, chatId: `bot:${bot.id}`, text: 'Hello', kind: 'private' }); | ||
| assert.equal(result.status, 'done'); | ||
| // The default first, then the offered Agents, primary first. | ||
| assert.deepEqual(calls.map((call) => call.agent), [undefined, 'mavis', 'general']); | ||
| assert.ok(warnings.some((warning) => warning.message === 'minimax_bot.agent_bound' && warning.detail.agent === 'general')); | ||
| }); | ||
|
|
||
| test('the Agent that worked is used for the next new session without probing again', async (t) => { | ||
| const { engine, store } = await fixture(t); | ||
| const first = { id: 'bot_a', name: 'Alice', role: 'Writer' }; | ||
| const second = { id: 'bot_b', name: 'Bob', role: 'Reviewer' }; | ||
| store.state.bots = [first, second]; | ||
| const calls = []; | ||
| let session = 0; | ||
| engine.agent = { | ||
| async agents() { | ||
| return { agents: [ | ||
| { name: 'mavis', displayName: 'Mavis', primary: true }, | ||
| { name: 'general', displayName: 'General', primary: false }, | ||
| ] }; | ||
| }, | ||
| async run(input, options) { | ||
| calls.push(input); | ||
| // Only a new session can fail to bind: a continued one keeps the Agent it was created with. | ||
| if (!input.sessionId && (!input.agent || input.agent === 'mavis')) throw bindFailure(); | ||
| session += 1; | ||
| return { ...turnStarted(options, `session-${session}`), status: 'finished', text: 'Done' }; | ||
| }, | ||
| }; | ||
| await engine.runBotTurn({ bot: first, chatId: `bot:${first.id}`, text: 'First', kind: 'private' }); | ||
| await engine.runBotTurn({ bot: second, chatId: `bot:${second.id}`, text: 'Second', kind: 'private' }); | ||
| // The second Bot opens its own session and goes straight to the Agent that worked. | ||
| assert.deepEqual(calls.map((call) => call.agent), [undefined, 'mavis', 'general', 'general']); | ||
| }); | ||
|
|
||
| test('a continued session never names an Agent', async (t) => { | ||
| const { engine, store } = await fixture(t); | ||
| const bot = { id: 'bot_a', name: 'Alice', role: 'Writer' }; | ||
| store.state.bots = [bot]; | ||
| const chatId = `bot:${bot.id}`; | ||
| await store.setSession(chatId, bot.id, { sessionId: 'session-1' }); | ||
| const calls = []; | ||
| engine.agent = { | ||
| async agents() { return { agents: [{ name: 'mavis', displayName: 'Mavis', primary: true }] }; }, | ||
| async run(input, options) { | ||
| calls.push(input); | ||
| return { ...turnStarted(options, 'session-1'), status: 'finished', text: 'Done' }; | ||
| }, | ||
| }; | ||
| const result = await engine.runBotTurn({ bot, chatId, text: 'Continue', kind: 'private' }); | ||
| assert.equal(result.status, 'done'); | ||
| assert.equal(calls.length, 1); | ||
| assert.equal('agent' in calls[0], false); | ||
| }); | ||
|
|
||
| test('a Host that cannot list Agents keeps the Host failure', async (t) => { | ||
| const { engine, store } = await fixture(t); | ||
| const bot = { id: 'bot_a', name: 'Alice', role: 'Writer' }; | ||
| store.state.bots = [bot]; | ||
| const chatId = `bot:${bot.id}`; | ||
| let calls = 0; | ||
| engine.agent = { | ||
| async run() { calls += 1; throw bindFailure(); }, | ||
| }; | ||
| const result = await engine.runBotTurn({ bot, chatId, text: 'Hello', kind: 'private' }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(calls, 1); | ||
| const messages = await store.getMessages(chatId); | ||
| assert.equal(messages.at(-1).errorCode, 'AGENT_UNAVAILABLE'); | ||
| }); | ||
|
|
||
| test('a failure that is not a bind failure is never retried with another Agent', async (t) => { | ||
| const { engine, store } = await fixture(t); | ||
| const bot = { id: 'bot_a', name: 'Alice', role: 'Writer' }; | ||
| store.state.bots = [bot]; | ||
| const calls = []; | ||
| engine.agent = { | ||
| async agents() { return { agents: [{ name: 'general', displayName: 'General', primary: false }] }; }, | ||
| async run(input) { | ||
| calls.push(input); | ||
| throw Object.assign(new Error('The Agent turn failed'), { code: 'AGENT_OUTCOME_UNKNOWN' }); | ||
| }, | ||
| }; | ||
| const result = await engine.runBotTurn({ bot, chatId: `bot:${bot.id}`, text: 'Hello', kind: 'private' }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(calls.length, 1); | ||
| assert.equal(calls[0].agent, undefined); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you drop the references to host implementation details here and just describe what the plugin does (e.g. "when the default Agent cannot be bound, …")?