Repository navigation
feat: add native advisor tool #9636
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
Merged
yiliang114
merged 62 commits into
QwenLM:main
from
ZijianZhang989:codex/advisor-native-tool
Sep 25, 2026
Merged
Changes from 59 commits
Commits
Show all changes
62 commits
Select commit
Hold shift + click to select a range
522b074
feat(cli): add native advisor tool
535f645
fix(core): harden native advisor consults
4676c5c
fix(core): run native advisor through forked agent
c13fc64
fix(cli): validate advisor startup model
c3cb456
fix(core): tolerate advisor JSON text responses
9567978
fix(cli): preserve advisor continuation order
2f390fb
test(advisor): streamline coverage
9269ca2
fix(cli): sync advisor display translations
4df7b7f
fix(cli): add advisor description to i18n baseline
f142a2e
fix(web-shell): translate advisor tool name
65b9e55
refactor(advisor): keep changes feature-scoped
8c8153e
fix(advisor): follow forked agent module move
7057ebe
fix(advisor): address blocking review findings
1147cd6
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
b236ca9
fix(advisor): address review suggestions
8792ef7
fix(advisor): address second review findings
8e9cf84
test(cli): align advisor settings expectations
5b1398e
fix(advisor): address third review findings
930056d
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
2e9498a
fix(advisor): align native tool with minimal scope
ed2e82c
fix(advisor): ignore workspace model settings
bae39b6
fix(core): register advisor permission aliases
2def1a8
fix(cli): avoid disabling stale advisor selection
23bd25e
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
b31faea
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
309a94f
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
3db972e
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
8911c6f
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
ce52b3b
fix(cli): address advisor review regressions
9aed08f
test(core): update telemetry swap mock config
4245950
fix(cli): align advisor picker fast model eligibility
fc763a5
fix(cli): reject unavailable advisor picker selections
1d031e7
fix(advisor): preserve manual command semantics
a573743
fix(cli): show advisor focus hint
d59335a
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
9a61fef
fix(advisor): stabilize structured review output
7c6a413
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
3652ae3
fix(advisor): reject non-object structured results
4801448
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
4a0ebdd
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
f50133a
fix(cli): allow runtime advisor models
a2a814b
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
3a1e43c
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
bb8ebd1
fix(cli): allow uncaptured runtime advisor model
b963636
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
184c1f4
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
24fa93c
test: align advisor tests with llm rename
32f44aa
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
d536a68
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
cda083b
fix(advisor): enforce independent model availability
fe10336
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
1910bd4
fix(advisor): report disabled tool selection
4b61aaf
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
9ce81f9
fix(advisor): tolerate extra review fields
3d276bd
Merge remote-tracking branch 'origin/main' into codex/advisor-native-…
14d7c5b
fix(advisor): preserve model routes and cache reuse after main sync
yiliang114 08fc82e
fix(advisor): sync settings coverage and retire hidden presentation a…
yiliang114 8824c5c
Merge branch 'main' into codex/advisor-native-tool
yiliang114 f10c2d9
fix(advisor): preserve schema results and honor deferred tool registr…
yiliang114 13ffa6e
fix(web-shell): keep the Advisor presentation alias
yiliang114 ebb4a30
fix(serve): keep serving the Advisor setting to the Web Shell
yiliang114 5c96f29
fix(serve): refuse the Advisor workspace write the merge strips
yiliang114 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,343 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
| import { join } from 'node:path'; | ||
| import { | ||
| CONTAINER_SANDBOX_NO_PROXY, | ||
| fakeServerHostOptions, | ||
| IS_CONTAINER_SANDBOX, | ||
| TestRig, | ||
| } from '../test-helper.js'; | ||
| import { | ||
| fakeToolCall, | ||
| startFakeOpenAIServer, | ||
| type FakeOpenAIServer, | ||
| } from '../fake-openai-server.js'; | ||
|
|
||
| type JsonObject = Record<string, unknown>; | ||
|
|
||
| let rig: TestRig | undefined; | ||
| let server: FakeOpenAIServer | undefined; | ||
|
|
||
| afterEach(async () => { | ||
| vi.unstubAllEnvs(); | ||
| await server?.close(); | ||
| await rig?.cleanup(); | ||
| server = undefined; | ||
| rig = undefined; | ||
| }); | ||
|
|
||
| function messages(body: JsonObject): JsonObject[] { | ||
| return Array.isArray(body['messages']) | ||
| ? body['messages'].filter( | ||
| (value): value is JsonObject => | ||
| typeof value === 'object' && value !== null, | ||
| ) | ||
| : []; | ||
| } | ||
|
|
||
| function contentText(content: unknown): string { | ||
| if (typeof content === 'string') return content; | ||
| if (!Array.isArray(content)) return ''; | ||
| return content | ||
| .map((part) => | ||
| typeof part === 'object' && | ||
| part !== null && | ||
| typeof (part as JsonObject)['text'] === 'string' | ||
| ? String((part as JsonObject)['text']) | ||
| : '', | ||
| ) | ||
| .join('\n'); | ||
| } | ||
|
|
||
| function requestText(body: JsonObject): string { | ||
| return messages(body) | ||
| .map((message) => contentText(message['content'])) | ||
| .join('\n'); | ||
| } | ||
|
|
||
| function toolNames(body: JsonObject): string[] { | ||
| return Array.isArray(body['tools']) | ||
| ? body['tools'].flatMap((tool) => { | ||
| if (typeof tool !== 'object' || tool === null) return []; | ||
| const fn = (tool as JsonObject)['function']; | ||
| if (typeof fn !== 'object' || fn === null) return []; | ||
| const name = (fn as JsonObject)['name']; | ||
| return typeof name === 'string' ? [name] : []; | ||
| }) | ||
| : []; | ||
| } | ||
|
|
||
| function configureEnv(testDir: string, baseUrl: string): void { | ||
| const noProxy = IS_CONTAINER_SANDBOX | ||
| ? CONTAINER_SANDBOX_NO_PROXY | ||
| : '127.0.0.1,localhost'; | ||
| vi.stubEnv('HOME', testDir); | ||
| vi.stubEnv('QWEN_HOME', join(testDir, '.qwen')); | ||
| vi.stubEnv('QWEN_RUNTIME_DIR', join(testDir, '.qwen')); | ||
| vi.stubEnv('OPENAI_API_KEY', 'fake-key'); | ||
| vi.stubEnv('OPENAI_BASE_URL', baseUrl); | ||
| vi.stubEnv('OPENAI_MODEL', 'executor-model'); | ||
| vi.stubEnv('QWEN_MODEL', 'executor-model'); | ||
| for (const name of [ | ||
| 'HTTP_PROXY', | ||
| 'HTTPS_PROXY', | ||
| 'ALL_PROXY', | ||
| 'http_proxy', | ||
| 'https_proxy', | ||
| 'all_proxy', | ||
| ]) { | ||
| vi.stubEnv(name, ''); | ||
| } | ||
| vi.stubEnv('NO_PROXY', noProxy); | ||
| vi.stubEnv('no_proxy', noProxy); | ||
| } | ||
|
|
||
| async function setupRig(baseUrl: string): Promise<TestRig> { | ||
| const nextRig = new TestRig(); | ||
| await nextRig.setup('native advisor tool', { | ||
| settings: { | ||
| modelProviders: { | ||
| openai: [ | ||
| { | ||
| id: 'executor-model', | ||
| name: 'Executor Model', | ||
| baseUrl, | ||
| envKey: 'OPENAI_API_KEY', | ||
| }, | ||
| { | ||
| id: 'advisor-model', | ||
| name: 'Advisor Model', | ||
| baseUrl, | ||
| envKey: 'OPENAI_API_KEY', | ||
| }, | ||
| ], | ||
| }, | ||
| security: { auth: { selectedType: 'openai' } }, | ||
| advisorModel: 'advisor-model', | ||
| ui: { enableFollowupSuggestions: false }, | ||
| }, | ||
| }); | ||
| configureEnv(nextRig.testDir!, baseUrl); | ||
| return nextRig; | ||
| } | ||
|
|
||
| async function runPrompt(prompt: string, advisor = 'advisor-model') { | ||
| const sessionId = crypto.randomUUID(); | ||
| const input = [ | ||
| { | ||
| type: 'control_request', | ||
| request_id: 'initialize-advisor-test', | ||
| request: { subtype: 'initialize' }, | ||
| }, | ||
| { | ||
| type: 'user', | ||
| session_id: sessionId, | ||
| message: { role: 'user', content: prompt }, | ||
| parent_tool_use_id: null, | ||
| }, | ||
| ] | ||
| .map((message) => JSON.stringify(message)) | ||
| .join('\n'); | ||
| return rig!.run( | ||
| { stdin: input }, | ||
| '--auth-type', | ||
| 'openai', | ||
| '--model', | ||
| 'executor-model', | ||
| '--advisor', | ||
| advisor, | ||
| '--openai-base-url', | ||
| server!.baseUrl, | ||
| '--openai-api-key', | ||
| 'fake-key', | ||
| '--input-format', | ||
| 'stream-json', | ||
| '--output-format', | ||
| 'stream-json', | ||
| ); | ||
| } | ||
|
|
||
| describe('native Advisor tool', () => { | ||
| it('forwards the transcript to a no-tools model and reinjects its review', async () => { | ||
| let evidenceFile = ''; | ||
| server = await startFakeOpenAIServer(({ body }) => { | ||
| if (body['model'] === 'advisor-model') { | ||
| return { | ||
| content: JSON.stringify({ | ||
| verdict: 'The approach is sound.', | ||
| risks: 'None found.', | ||
| missingEvidence: 'None found.', | ||
| recommendation: 'Finish the task.', | ||
| }), | ||
| }; | ||
| } | ||
| if (body['stream'] !== true) { | ||
| return { content: '{"selected_memories":[]}' }; | ||
| } | ||
| const text = requestText(body); | ||
| if (text.includes('The approach is sound.')) { | ||
| return { content: 'Final answer after Advisor feedback.' }; | ||
| } | ||
| if (text.includes('wire-level evidence')) { | ||
| return { | ||
| content: 'I found the evidence and will ask for a second opinion.', | ||
| toolCalls: [fakeToolCall('advisor', {}, 'advisor-call')], | ||
| }; | ||
| } | ||
| if (text.includes('Review the task and finish.')) { | ||
| return { | ||
| content: 'I will inspect the evidence first.', | ||
| toolCalls: [ | ||
| fakeToolCall('read_file', { file_path: evidenceFile }, 'read-call'), | ||
| ], | ||
| }; | ||
| } | ||
| return { content: 'Prior answer with unique context.' }; | ||
| }, fakeServerHostOptions()); | ||
|
|
||
| rig = await setupRig(server.baseUrl); | ||
| evidenceFile = rig.createFile('evidence.txt', 'wire-level evidence'); | ||
| const sessionId = crypto.randomUUID(); | ||
| const input = [ | ||
| { | ||
| type: 'control_request', | ||
| request_id: 'initialize-advisor-test', | ||
| request: { subtype: 'initialize' }, | ||
| }, | ||
| { | ||
| type: 'user', | ||
| session_id: sessionId, | ||
| message: { role: 'user', content: 'Remember this prior request.' }, | ||
| parent_tool_use_id: null, | ||
| }, | ||
| { | ||
| type: 'user', | ||
| session_id: sessionId, | ||
| message: { role: 'user', content: 'Review the task and finish.' }, | ||
| parent_tool_use_id: null, | ||
| }, | ||
| ] | ||
| .map((message) => JSON.stringify(message)) | ||
| .join('\n'); | ||
|
|
||
| const output = await rig.run( | ||
| { stdin: input }, | ||
| '--auth-type', | ||
| 'openai', | ||
| '--model', | ||
| 'executor-model', | ||
| '--advisor', | ||
| 'advisor-model', | ||
| '--openai-base-url', | ||
| server.baseUrl, | ||
| '--openai-api-key', | ||
| 'fake-key', | ||
| '--input-format', | ||
| 'stream-json', | ||
| '--output-format', | ||
| 'stream-json', | ||
| ); | ||
|
|
||
| expect(output).toContain('Final answer after Advisor feedback.'); | ||
| const requests = server.requests.map((request) => request.body); | ||
| const executorRequests = requests.filter( | ||
| (body) => body['stream'] === true && body['model'] === 'executor-model', | ||
| ); | ||
| const advisorRequests = requests.filter( | ||
| (body) => body['model'] === 'advisor-model', | ||
| ); | ||
| expect( | ||
| executorRequests.some((request) => | ||
| toolNames(request).includes('advisor'), | ||
| ), | ||
| ).toBe(true); | ||
| expect(advisorRequests).toHaveLength(1); | ||
| expect(toolNames(advisorRequests[0]!)).toEqual(['respond_in_schema']); | ||
|
|
||
| const advisorText = requestText(advisorRequests[0]!); | ||
| const evidenceText = messages(advisorRequests[0]!) | ||
| .map((message) => contentText(message['content'])) | ||
| .find((text) => text.startsWith('{')); | ||
| expect(evidenceText).toBeDefined(); | ||
| const evidence = JSON.parse(evidenceText!) as JsonObject; | ||
| expect(advisorText).toContain('independent senior advisor'); | ||
| expect(JSON.stringify(evidence['executorSystemInstruction'])).toContain( | ||
| 'You are Qwen Code', | ||
| ); | ||
| expect(JSON.stringify(evidence['executorToolDeclarations'])).toContain( | ||
| 'read_file', | ||
| ); | ||
| const transcript = JSON.stringify(evidence['transcript']); | ||
| expect(transcript).toContain('Remember this prior request.'); | ||
| expect(transcript).toContain('Prior answer with unique context.'); | ||
| expect(transcript).toContain('wire-level evidence'); | ||
| expect(transcript).toContain('I will inspect the evidence first.'); | ||
| expect(transcript).toContain( | ||
| 'I found the evidence and will ask for a second opinion.', | ||
| ); | ||
| }); | ||
|
|
||
| it('lets the executor continue after an Advisor failure', async () => { | ||
| server = await startFakeOpenAIServer(({ body }) => { | ||
| if (body['model'] === 'advisor-model') { | ||
| return { content: 'invalid advisor output' }; | ||
| } | ||
| if (body['stream'] !== true) { | ||
| return { content: '{"selected_memories":[]}' }; | ||
| } | ||
| const text = requestText(body); | ||
| if (text.includes('Advisor returned invalid structured output.')) { | ||
|
ZijianZhang989 marked this conversation as resolved.
|
||
| return { content: 'Executor continued without Advisor.' }; | ||
| } | ||
| return { | ||
| content: 'I will ask Advisor.', | ||
| toolCalls: [fakeToolCall('advisor', {}, 'advisor-failure-call')], | ||
| }; | ||
| }, fakeServerHostOptions()); | ||
| rig = await setupRig(server.baseUrl); | ||
|
|
||
| const output = await runPrompt('Test Advisor failure handling.'); | ||
|
|
||
| expect(output).toContain('Executor continued without Advisor.'); | ||
| expect( | ||
| server.requests.filter( | ||
| (request) => request.body['model'] === 'advisor-model', | ||
| ), | ||
| ).toHaveLength(1); | ||
| }); | ||
|
|
||
| it('does not expose or request Advisor when the session override is off', async () => { | ||
| server = await startFakeOpenAIServer( | ||
| ({ body }) => ({ | ||
| content: | ||
| body['stream'] === true | ||
| ? 'Advisor is not available in this request.' | ||
| : '{"selected_memories":[]}', | ||
| }), | ||
| fakeServerHostOptions(), | ||
| ); | ||
| rig = await setupRig(server.baseUrl); | ||
|
|
||
| const output = await runPrompt('Check the available tools.', 'off'); | ||
|
|
||
| expect(output).toContain('Advisor is not available in this request.'); | ||
| const executorRequests = server.requests.filter( | ||
| (request) => request.body['model'] === 'executor-model', | ||
| ); | ||
| expect( | ||
| executorRequests.every( | ||
| (request) => !toolNames(request.body).includes('advisor'), | ||
| ), | ||
| ).toBe(true); | ||
| expect( | ||
| server.requests.some( | ||
| (request) => request.body['model'] === 'advisor-model', | ||
| ), | ||
| ).toBe(false); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.