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
16 changes: 10 additions & 6 deletions packages/dmoss-agent/src/cli-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { execSync } from 'node:child_process';
import os from 'node:os';
import { resolveCliAgentRuntimeOptions } from './cli/agent-runtime.js';
import { createCliToolApprovalHook, resolveCliSafetyMode } from './cli/approval.js';
import { CliConfigWriteError, loadCliConfigFile, loadEnvFromAncestors, resolveCliConfig, resolveConfigDir, safeProcessCwd } from './cli/config.js';
import { CliConfigFileError, CliConfigWriteError, loadCliConfigFile, loadEnvFromAncestors, resolveCliConfig, resolveConfigDir, safeProcessCwd } from './cli/config.js';
import { parseCliArgs } from './cli/args.js';
import { renderCliDoctor, cliDoctorHasFailure } from './cli/doctor.js';
import { displayHelp, displayVersion } from './cli/help.js';
import { createConfiguredGuardrailHooks } from './cli/guardrails.js';
import { createConfiguredHookCallbacks } from './cli/hooks.js';
import { DMOSS_CLI_IDENTITY } from './cli/identity.js';
import { buildDmossCliIdentity } from './cli/identity.js';
import type { AgentHooks } from './core/agent/agent-hooks.js';
import { createCliProvider } from './cli/providers.js';
import type { CliProviderRuntimeConfig } from './cli/providers.js';
Expand Down Expand Up @@ -473,7 +473,9 @@ async function main() {
const agent = new DmossAgent({
llmProvider: createCliProvider(providerConfig), sessionStore, model,
workspaceDir: workspace,
baseSystemPrompt: DMOSS_CLI_IDENTITY,
// Keep the Moss persona, but name the actual model so the agent can answer
// "which model are you?" honestly instead of substituting "Moss".
baseSystemPrompt: buildDmossCliIdentity({ model, usingBundledDefault: resolvedConfig.usingBundledDefault }),
enableToolOutputTruncation: true, extraPromptLayers, skillPipeline,
memoryContextProvider: () => memoryManager.buildDigest(),
...resolveCliAgentRuntimeOptions(resolvedConfig),
Expand Down Expand Up @@ -582,9 +584,11 @@ async function main() {
}

main().catch((err) => {
// Config-write failures already carry a clean, actionable one-liner — show it
// alone instead of a raw Node stack from writeFileSync.
if (err instanceof CliConfigWriteError) {
// Config file errors already carry a clean, actionable one-liner — show it
// alone instead of a raw Node stack. A malformed/hand-edited config.json
// (parse error → CliConfigFileError) is just as likely as a write failure,
// so both get the same friendly treatment on every entry point.
if (err instanceof CliConfigWriteError || err instanceof CliConfigFileError) {
console.error(`moss: ${err.message}`);
process.exit(1);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/dmoss-agent/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export function displayHelp(c: Colors, options: { all?: boolean } = {}): void {
` ${c.yellow('--base-url')} ${c.dim('<url>')} override provider base URL`,
` ${c.yellow('--session')} ${c.dim('<key>')} continue or create a named session key`,
` ${c.yellow('--last')} with resume/fork, use latest session`,
` ${c.yellow('--ask-for-approval')} ${c.dim('<p>')} never | on-request | untrusted`,
` ${c.yellow('--ask-for-approval')} ${c.dim('<p>')} never | prompt | on-request | read-only | workspace-write | full-access`,
` ${c.yellow('--read-only')} block mutating tools`,
` ${c.yellow('--workspace-write')} allow workspace writes/exec with approval (default)`,
` ${c.yellow('--full-access')} allow device/external tools with approval`,
Expand Down
58 changes: 45 additions & 13 deletions packages/dmoss-agent/src/cli/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,49 @@
* The CLI is itself a Moss host, so it owns the agent's identity (Moss core
* stays vendor/persona-neutral). Without this, the model has no instruction
* about who it is and free-associates a name (it was introducing itself as
* another assistant). Passed as `baseSystemPrompt`, so it sits first in the system prompt,
* ahead of the robotics domain prompt. Kept short and bilingual for
* cross-model consistency.
* another assistant). Passed as `baseSystemPrompt`, so it sits first in the
* system prompt, ahead of the robotics domain prompt. Kept short and bilingual
* for cross-model consistency.
*
* Persona vs. model honesty: "Moss" is the product/persona name and is kept —
* the agent must not role-play as a different assistant product. But the
* underlying language model is disclosed honestly: when the user asks which
* model powers Moss, the agent names the actual model instead of substituting
* "Moss" for the model name.
*/
export function buildDmossCliIdentity(
options: { model?: string; usingBundledDefault?: boolean } = {},
): string {
const modelLineEn = options.usingBundledDefault
? " You currently run on D-Robotics' built-in model gateway."
: options.model
? ` You currently run on the \`${options.model}\` model.`
: '';
const modelLineZh = options.usingBundledDefault
? ' 你当前运行在地瓜机器人的内置模型网关上。'
: options.model
? ` 你当前运行在 \`${options.model}\` 模型上。`
: '';
return [
'You are Moss, an AI agent developed by D-Robotics (地瓜机器人). You help users get work done across ' +
'their computer and RDK boards — code, device operations, and ROS/robotics tasks. ' +
'Moss is your name and product identity; keep it and do not role-play as a different assistant product. ' +
'But be honest about the model underneath: if the user asks which language model powers you, name the actual ' +
'model truthfully — do not substitute "Moss" for the model name.' +
modelLineEn,
'',
'你是 Moss,地瓜机器人(D-Robotics)研发的 Agent。你在用户的电脑与 RDK 开发板上,' +
'协助完成代码、设备操作与 ROS/机器人任务。Moss 是你的名字与产品身份,请保持,不要扮演成其他助手产品。' +
'但对底层模型要诚实:用户若问你用的是什么模型,请如实说出实际模型,不要用"Moss"代替模型名。' +
modelLineZh,
'',
'Think through each step before you act. 每一步都要先想清楚再做。',
].join('\n');
}

/**
* Back-compatible identity string with no specific model named. Prefer
* {@link buildDmossCliIdentity} so the active model is disclosed honestly.
* @public
*/
export const DMOSS_CLI_IDENTITY = [
'You are Moss, an AI agent developed by D-Robotics (地瓜机器人). You help users get work done across ' +
'their computer and RDK boards — code, device operations, and ' +
'ROS/robotics tasks. Identify yourself as Moss; never claim to be any other assistant.',
'',
'你是 Moss,地瓜机器人(D-Robotics)研发的 Agent。你在用户的电脑与 RDK 开发板上,' +
'协助完成代码、设备操作与 ROS/机器人任务。请以 Moss 自称,不要自称其他助手。',
'',
'Think through each step before you act. 每一步都要先想清楚再做。',
].join('\n');
export const DMOSS_CLI_IDENTITY = buildDmossCliIdentity();
19 changes: 15 additions & 4 deletions packages/dmoss-agent/src/cli/model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,24 @@ export function parseCustomModelConfigInput(input: string): CustomModelConfigPar
}

export function formatCustomModelConfigInstructions(configPath?: string): string {
// Preset-prefilled, copy-paste-ready lines for the common first-party
// providers so the user only pastes their key — no looking up base_url/model.
// `moss setup` remains the guided alternative with a hidden key field.
const presetLine = (p: CliProviderPreset): string => {
const preset = PROVIDER_PRESETS[p];
return ` ${preset.displayName.padEnd(10)} /model config provider=${p} base_url=${preset.defaultBaseUrl} model_name=${preset.defaultModel} key=<paste-your-key>`;
};
return [
'Configure a custom model',
'Add your own model & key',
` config file ${configPath || '(default user config)'}`,
' command /model config base_url=<url> key=<api-key> model_name=<model> [image_input=true]',
'',
'Example:',
' /model config base_url=https://your-gateway.example/v1 key=sk-... model_name=your-model-name',
'Pick your provider, paste your key after key=, and press Enter:',
presetLine('deepseek'),
presetLine('qwen'),
presetLine('openai'),
' Custom /model config base_url=<url> model_name=<model> key=<api-key> [image_input=true]',
'',
'Or run `moss setup` for a guided prompt with a hidden key field.',
].join('\n');
}

Expand Down
11 changes: 10 additions & 1 deletion packages/dmoss-agent/src/cli/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,16 @@ async function callOpenAI(

if (!res.ok) {
const text = await res.text();
throw providerError('OpenAI-compatible', res.status, text);
const error = providerError('OpenAI-compatible', res.status, text);
// A fresh install talks to the shared built-in gateway. When that gateway
// is over quota / rate-limited / payment-required, the upstream body is a
// raw (often non-English) limit message — a newcomer cannot tell the free
// pool is just depleted. Give them the one actionable way forward.
if (config.usingBundledDefault && (res.status === 429 || res.status === 402 || res.status === 503)) {
error.message +=
'\nThe free built-in Moss model is over its shared quota right now — run `moss setup` to use your own model key (DeepSeek/Qwen/OpenAI/Anthropic/any OpenAI-compatible), or try again later.';
}
throw error;
}

const data: OpenAIResponse = (await res.json()) as OpenAIResponse;
Expand Down
78 changes: 70 additions & 8 deletions packages/dmoss-agent/src/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,44 @@ import {
saveConfigFileAtPath,
type CliProviderPreset,
type ConfigFile,
type ResolvedCliConfig,
} from './config.js';
import {
clearDmossCommunityAuthSession,
formatCommunityAuthStatus,
getDmossCommunityAuthStatus,
} from './community-auth.js';
import { loadModelChoicesForRuntime } from './model-catalog.js';

/**
* After saving a model config, check whether the gateway is actually reachable
* with the configured key, so `moss setup` reports a verified outcome instead
* of an unconditional "Saved configuration". Reuses the live /v1/models probe
* (timeout-bounded, never throws). Anthropic has no /v1/models, so it is
* reported as saved-but-unchecked rather than failed.
* @internal
*/
export async function probeSetupReachability(
config: Partial<ResolvedCliConfig>,
options: { fetchImpl?: typeof fetch; timeoutMs?: number } = {},
): Promise<string> {
let result;
try {
result = await loadModelChoicesForRuntime(config, config.model ?? '', {
timeoutMs: options.timeoutMs ?? 2500,
fetchImpl: options.fetchImpl,
});
} catch {
return 'Saved, but could not reach the gateway with this key — check baseUrl/key, then re-run `moss setup`.';
}
if (result.source === 'live') {
return `Configured and reachable — ${result.choices.length} model(s) available from the gateway.`;
}
if (result.warning) {
return 'Saved, but could not reach the gateway with this key — check baseUrl/key, then re-run `moss setup`.';
}
return `Key saved (${result.providerLabel} — skipping live reachability check).`;
}

function print(line = ''): void {
output.write(`${line}\n`);
Expand Down Expand Up @@ -319,7 +351,8 @@ export function renderConfigUsage(): string {
' moss config show',
' moss config show --json',
' moss config validate [--strict] [--json]',
' moss config set <profile|provider|model|baseUrl|imageInput|workspace|safetyMode|approvalPolicy|trustedTools|deniedTools|promptCache|promptCacheDebug|guardrails.*|mcp.enabled|mcp.configPath|agent.*> <value>',
' moss config set <provider|model|baseUrl|apiKey|imageInput> <value> # model',
' moss config set <profile|safetyMode|approvalPolicy|trustedTools|deniedTools|promptCache|promptCacheDebug|guardrails.*|mcp.*|agent.*> <value> # operational',
' moss config set --project <key> <value>',
' moss config unset <key>',
' moss config unset --project <key>',
Expand All @@ -336,6 +369,7 @@ export function renderConfigUsage(): string {
' moss config set provider openai-compatible',
' moss config set model <your-model>',
' moss config set baseUrl https://your-gateway.example/v1',
' moss config set apiKey <your-api-key>',
' moss config set imageInput true',
' moss config set --project safetyMode workspace-write',
' moss config set approvalPolicy prompt',
Expand Down Expand Up @@ -418,10 +452,25 @@ export async function runSetupWizard(): Promise<void> {

const defaultModel = current.model || preset.defaultModel;
const defaultBaseUrl = current.baseUrl || preset.defaultBaseUrl;
const modelAnswer = rl ? await questionWith(rl, `Model [${defaultModel}]: `) : nextPipedAnswer();
const model = modelAnswer || defaultModel;
const baseUrlAnswer = rl ? await questionWith(rl, `Base URL [${defaultBaseUrl}]: `) : nextPipedAnswer();
const baseUrlInput = baseUrlAnswer || defaultBaseUrl;
// Key-only fast path: a first-party preset already carries a sensible model
// and base URL, so an interactive user goes straight to the API key (2 inputs
// instead of 4). openai-compatible has no default endpoint, so it keeps the
// full prompts. Non-TTY (piped) always asks every field in order so scripted
// answer lines stay aligned with the existing tests.
const fastPath = Boolean(rl) && provider !== 'openai-compatible';
let model: string;
let baseUrlInput: string;
if (fastPath) {
model = defaultModel;
baseUrlInput = defaultBaseUrl;
print(`Using ${preset.displayName} defaults — model ${defaultModel}, base URL ${defaultBaseUrl}.`);
print('(Change later with `moss config set model <name>` or `moss config set baseUrl <url>`.)');
} else {
const modelAnswer = rl ? await questionWith(rl, `Model [${defaultModel}]: `) : nextPipedAnswer();
model = modelAnswer || defaultModel;
const baseUrlAnswer = rl ? await questionWith(rl, `Base URL [${defaultBaseUrl}]: `) : nextPipedAnswer();
baseUrlInput = baseUrlAnswer || defaultBaseUrl;
}
if (!isHttpUrl(baseUrlInput)) {
rl?.close();
print(`Setup cancelled: base URL must be a full http(s) URL, got: ${baseUrlInput}`);
Expand Down Expand Up @@ -460,6 +509,14 @@ export async function runSetupWizard(): Promise<void> {
print(`Model: ${model}`);
print(`Base URL: ${withoutSecret(baseUrl)}`);
print(`Image input: ${imageInput ? 'enabled' : 'disabled'}${imageInput ? '' : ' (enable with `moss config set imageInput true` for a vision-capable gateway)'}`);
// No success claim without a verified outcome: probe the gateway interactively
// so the user knows the key actually works, not just that it was written.
// Skipped for non-TTY/scripted setup so CI does not make a network call.
if (input.isTTY) {
print('');
print('Checking the gateway…');
print(await probeSetupReachability({ provider, model, baseUrl, apiKey }));
}
print('');
print('Try `dmoss "explain this project and how to run it"` or run `dmoss` for interactive mode.');
}
Expand Down Expand Up @@ -584,7 +641,7 @@ function buildProjectConfigTemplate(): ConfigFile {
}

function supportedConfigKeys(): string {
return 'Supported keys: profile, provider, model, baseUrl, imageInput, workspace, safetyMode, approvalPolicy, trustedTools, deniedTools, promptCache, promptCacheDebug, guardrails.input.blockPatterns, guardrails.input.redactPatterns, guardrails.output.blockPatterns, guardrails.output.redactPatterns, mcp.enabled, mcp.configPath, agent.maxTurns, agent.contextTokens, agent.compaction.reserveTokens, agent.compaction.keepRecentTokens';
return 'Supported keys — model: provider, model, baseUrl, apiKey, imageInput; operational: profile, workspace, safetyMode, approvalPolicy, trustedTools, deniedTools, promptCache, promptCacheDebug, guardrails.input.blockPatterns, guardrails.input.redactPatterns, guardrails.output.blockPatterns, guardrails.output.redactPatterns, mcp.enabled, mcp.configPath, agent.maxTurns, agent.contextTokens, agent.compaction.reserveTokens, agent.compaction.keepRecentTokens';
}

function removeEmptyNestedConfig(config: ConfigFile): ConfigFile {
Expand Down Expand Up @@ -664,6 +721,7 @@ export function runConfigSet(args: string[], startDir = process.cwd()): void {
next.provider = provider;
}
else if (key === 'model') next.model = value;
else if (key === 'apiKey') next.apiKey = value;
else if (key === 'baseUrl') {
if (!isHttpUrl(value)) {
print(`Invalid baseUrl: ${value.trim()}`);
Expand Down Expand Up @@ -800,6 +858,10 @@ export function runConfigSet(args: string[], startDir = process.cwd()): void {
saveConfigFileAtPath(next, target.configPath);
const scope = target.scope === 'project' ? 'project ' : '';
print(`[config] ${scope}${key} updated in ${target.configPath}`);
if (key === 'apiKey') {
// Never echo the key value; just confirm and flag the shell-history caveat.
print('[config] The API key is stored in the config file; avoid leaving it in shell history.');
}
}

export function runConfigUnset(args: string[], startDir = process.cwd()): void {
Expand Down Expand Up @@ -893,8 +955,8 @@ export function printMissingConfigGuidance(interactive: boolean, options: { bund
print('Script path (no TTY — model settings are read from config files, never env vars):');
print(' moss config set provider deepseek');
print(' moss config set model deepseek-chat');
print(' write the API key with `moss setup` once, or provide a config file:');
print(' moss --config-file /path/to/config.json # {"provider":"deepseek","apiKey":"..."}');
print(' moss config set apiKey <your-api-key>');
print(' # or point Moss at a JSON file: moss --config-file /path/to/config.json # {"provider":"deepseek","apiKey":"..."}');
print('');
if (interactive) {
print('You can run setup now, then start `dmoss` again.');
Expand Down
Loading
Loading