Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# .gitkeep file auto-generated at 2026-07-30T18:32:11.164Z for PR creation at branch issue-285-531ca23a5284 for issue https://github.com/link-assistant/agent/issues/285
17 changes: 17 additions & 0 deletions js/.changeset/fix-unknown-model-prompt-branding-version.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@link-assistant/agent': patch
---

Fix three defects that made a session's behaviour unattributable (#285):

- Unknown model ids no longer silently fall back to the without-todo prompt.
Prompt selection is now table-driven with an explicit, logged default
(`anthropic`, which includes the todo/task-tracking discipline). Models that
genuinely break on todo tools can opt out via `AGENT_SYSTEM_PROMPT`.
- The product identity (name, repo, issues and docs URLs) lives in one module
and is substituted into prompts and tool descriptions at render time, so the
agent no longer tells users it is `opencode` or sends bug reports to
`sst/opencode`.
- Session records report the real package version instead of the hard-coded
`agent-cli-1.0.0`; `--version`, the process log and session records now all
read the same value.
47 changes: 47 additions & 0 deletions js/src/branding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Single source of truth for the product identity that is exposed to models
* and to users.
*
* Several system prompts in `src/session/prompt/*.txt` are kept byte-identical
* to their upstream (opencode) originals so they can be re-synced without
* conflicts. Instead of editing those files, the identity is substituted at
* render time by {@link applyBranding}, so a rename is one edit here rather
* than one edit per prompt file. See issue #285.
*/
export namespace Branding {
/** Product name as it should appear in prose. */
export const NAME = 'Agent';
/** Product name as it appears in a shell (the binary name). */
export const BINARY = 'agent';
/** Canonical source repository. */
export const REPO_URL = 'https://github.com/link-assistant/agent';
/** Where users should report issues with this tool. */
export const ISSUES_URL = 'https://github.com/link-assistant/agent/issues';
/** Where documentation about this tool lives. */
export const DOCS_URL = 'https://github.com/link-assistant/agent#readme';

/**
* Ordered replacements. URLs must be replaced before the bare product name,
* otherwise the name substitution would corrupt the URLs.
*/
const REPLACEMENTS: [RegExp, string][] = [
[/https:\/\/github\.com\/sst\/opencode\/issues/gi, ISSUES_URL],
[/https:\/\/github\.com\/sst\/opencode/gi, REPO_URL],
[/https:\/\/opencode\.ai\/docs/gi, DOCS_URL],
[/https:\/\/opencode\.ai/gi, DOCS_URL],
[/sst\/opencode/gi, 'link-assistant/agent'],
[/opencode/gi, NAME],
];

/**
* Replace every upstream product reference in `text` with this product's
* identity. Safe to call on text that contains no references.
*/
export function apply(text: string): string {
let result = text;
for (const [pattern, replacement] of REPLACEMENTS) {
result = result.replace(pattern, replacement);
}
return result;
}
}
26 changes: 8 additions & 18 deletions js/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,12 @@ import {
outputInput,
} from './cli/output.ts';
import stripAnsi from 'strip-ansi';
import { createRequire } from 'module';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';

const require = createRequire(import.meta.url);
let pkg;
try {
pkg = require('../package.json');
} catch (_e) {
// Fallback: read package.json directly
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkgPath = join(__dirname, '../package.json');
const pkgContent = readFileSync(pkgPath, 'utf8');
pkg = JSON.parse(pkgContent);
}
import { resolve as resolvePath } from 'path';
import { VERSION } from './version.ts';

// Version comes from the shared module so that `--version`, the process log
// and stored session records can never disagree (#285).
const pkg = { version: VERSION };

// Track if any errors occurred during execution
let hasError = false;
Expand Down Expand Up @@ -226,7 +216,7 @@ async function readSystemMessages(argv) {
let appendSystemMessage = argv['append-system-message'];

if (argv['system-message-file']) {
const resolvedPath = require('path').resolve(
const resolvedPath = resolvePath(
process.cwd(),
argv['system-message-file']
);
Expand All @@ -242,7 +232,7 @@ async function readSystemMessages(argv) {
}

if (argv['append-system-message-file']) {
const resolvedPath = require('path').resolve(
const resolvedPath = resolvePath(
process.cwd(),
argv['append-system-message-file']
);
Expand Down
3 changes: 2 additions & 1 deletion js/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SessionPrompt } from './prompt';
import { fn } from '../util/fn';
import { Command } from '../command';
import { Snapshot } from '../snapshot';
import { VERSION } from '../version';

export namespace Session {
const log = Log.create({ service: 'session' });
Expand Down Expand Up @@ -226,7 +227,7 @@ export namespace Session {
}) {
const result: Info = {
id: Identifier.descending('session', input.id),
version: 'agent-cli-1.0.0',
version: VERSION,
projectID: Instance.project.id,
directory: input.directory,
parentID: input.parentID,
Expand Down
113 changes: 97 additions & 16 deletions js/src/session/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,105 @@ import PROMPT_SUMMARIZE from './prompt/summarize.txt';
import PROMPT_TITLE from './prompt/title.txt';
import PROMPT_CODEX from './prompt/codex.txt';
import PROMPT_GROK_CODE from './prompt/grok-code.txt';
import { Branding } from '../branding';
import { Log } from '../util/log';

export namespace SystemPrompt {
const log = Log.create({ service: 'system-prompt' });

export function header(providerID: string) {
if (providerID.includes('anthropic'))
return [PROMPT_ANTHROPIC_SPOOF.trim()];
return [];
}

/** Identifiers of the selectable system prompts. */
export type PromptID =
| 'anthropic'
| 'anthropic-without-todo'
| 'beast'
| 'codex'
| 'gemini'
| 'grok-code'
| 'polaris';

const RAW: Record<PromptID, string> = {
anthropic: PROMPT_ANTHROPIC,
'anthropic-without-todo': PROMPT_ANTHROPIC_WITHOUT_TODO,
beast: PROMPT_BEAST,
codex: PROMPT_CODEX,
gemini: PROMPT_GEMINI,
'grok-code': PROMPT_GROK_CODE,
polaris: PROMPT_POLARIS,
};

/**
* The prompt used for any model that is not matched by an explicit rule.
* It is the full prompt (with todo/task-tracking discipline); models that
* genuinely break on todo tools must opt out explicitly via the
* `AGENT_SYSTEM_PROMPT` environment variable. See issue #285.
*/
export const DEFAULT_PROMPT_ID: PromptID = 'anthropic';

const RULES: { matches: (modelID: string) => boolean; id: PromptID }[] = [
{ matches: (m) => m.includes('gpt-5'), id: 'codex' },
{
matches: (m) =>
m.includes('gpt-') || m.includes('o1') || m.includes('o3'),
id: 'beast',
},
{ matches: (m) => m.includes('gemini-'), id: 'gemini' },
{ matches: (m) => m.includes('claude'), id: 'anthropic' },
{ matches: (m) => m.includes('polaris-alpha'), id: 'polaris' },
{ matches: (m) => m.includes('grok-code'), id: 'grok-code' },
];

export function isPromptID(value: string): value is PromptID {
return value in RAW;
}

/**
* Resolve which prompt a model gets, and why.
*
* Resolution order:
* 1. explicit override via the `AGENT_SYSTEM_PROMPT` environment variable;
* 2. an explicit rule matching the model id;
* 3. {@link DEFAULT_PROMPT_ID}.
*/
export function resolve(modelID: string): { id: PromptID; reason: string } {
const override = process.env['AGENT_SYSTEM_PROMPT']?.trim();
if (override) {
if (isPromptID(override))
return { id: override, reason: 'AGENT_SYSTEM_PROMPT override' };
log.warn(() => ({
message: 'unknown AGENT_SYSTEM_PROMPT value, ignoring',
value: override,
known: Object.keys(RAW),
}));
}
for (const rule of RULES) {
if (rule.matches(modelID))
return { id: rule.id, reason: `matched model id ${modelID}` };
}
return {
id: DEFAULT_PROMPT_ID,
reason: `default for unknown model ${modelID}`,
};
}

/** Get a prompt by id, with the product identity substituted in (#285). */
export function text(id: PromptID): string {
return Branding.apply(RAW[id]);
}

export function provider(modelID: string) {
if (modelID.includes('gpt-5')) return [PROMPT_CODEX];
if (
modelID.includes('gpt-') ||
modelID.includes('o1') ||
modelID.includes('o3')
)
return [PROMPT_BEAST];
if (modelID.includes('gemini-')) return [PROMPT_GEMINI];
if (modelID.includes('claude')) return [PROMPT_ANTHROPIC];
if (modelID.includes('polaris-alpha')) return [PROMPT_POLARIS];
if (modelID.includes('grok-code')) return [PROMPT_GROK_CODE];
return [PROMPT_ANTHROPIC_WITHOUT_TODO];
const resolved = resolve(modelID);
log.info(() => ({
message: `system prompt: ${resolved.id} (${resolved.reason})`,
prompt: resolved.id,
model: modelID,
}));
return [text(resolved.id)];
}

export async function environment() {
Expand Down Expand Up @@ -149,18 +227,21 @@ export namespace SystemPrompt {
export function summarize(providerID: string) {
switch (providerID) {
case 'anthropic':
return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_SUMMARIZE];
return [
PROMPT_ANTHROPIC_SPOOF.trim(),
Branding.apply(PROMPT_SUMMARIZE),
];
default:
return [PROMPT_SUMMARIZE];
return [Branding.apply(PROMPT_SUMMARIZE)];
}
}

export function title(providerID: string) {
switch (providerID) {
case 'anthropic':
return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_TITLE];
return [PROMPT_ANTHROPIC_SPOOF.trim(), Branding.apply(PROMPT_TITLE)];
default:
return [PROMPT_TITLE];
return [Branding.apply(PROMPT_TITLE)];
}
}
}
3 changes: 2 additions & 1 deletion js/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import z from 'zod';
import { spawn } from 'child_process';
import { Tool } from './tool';
import DESCRIPTION from './bash.txt';
import { Branding } from '../branding';
import { Log } from '../util/log';
import { Instance } from '../project/instance';
import { lazy } from '../util/lazy';
Expand Down Expand Up @@ -53,7 +54,7 @@ const parser = lazy(async () => {
});

export const BashTool = Tool.define('bash', {
description: DESCRIPTION,
description: Branding.apply(DESCRIPTION),
parameters: z.object({
command: z.string().describe('The command to execute'),
timeout: z.number().describe('Optional timeout in milliseconds').optional(),
Expand Down
25 changes: 25 additions & 0 deletions js/src/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Single source of truth for the running version.
*
* The version is read from the package manifest, so `--version`, the process
* log and stored session records can never disagree. See issue #285.
*/
import { createRequire } from 'module';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';

function read(): string {
try {
const require = createRequire(import.meta.url);
return require('../package.json').version;
} catch {
// Fallback: read package.json directly (e.g. when `require` of JSON is
// unavailable in the current runtime configuration).
const here = dirname(fileURLToPath(import.meta.url));
return JSON.parse(readFileSync(join(here, '../package.json'), 'utf8'))
.version;
}
}

export const VERSION: string = read();
Loading