protoagent 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/App.js CHANGED
@@ -12,7 +12,8 @@ import { TextInput, Select, PasswordInput } from '@inkjs/ui';
12
12
  import BigText from 'ink-big-text';
13
13
  import { OpenAI } from 'openai';
14
14
  import { readConfig, writeConfig, resolveApiKey } from './config.js';
15
- import { getProvider, getModelPricing, SUPPORTED_MODELS } from './providers.js';
15
+ import { loadRuntimeConfig } from './runtime-config.js';
16
+ import { getAllProviders, getProvider, getModelPricing, getRequestDefaultParams } from './providers.js';
16
17
  import { runAgenticLoop, initializeMessages, } from './agentic-loop.js';
17
18
  import { setDangerouslyAcceptAll, setApprovalHandler, clearApprovalHandler } from './tools/index.js';
18
19
  import { setLogLevel, LogLevel, initLogFile, logger } from './utils/logger.js';
@@ -137,8 +138,31 @@ function buildClient(config) {
137
138
  const clientOptions = {
138
139
  apiKey,
139
140
  };
140
- if (provider?.baseURL) {
141
- clientOptions.baseURL = provider.baseURL;
141
+ // baseURL: env var override takes precedence over provider default
142
+ const baseURLOverride = process.env.PROTOAGENT_BASE_URL?.trim();
143
+ const baseURL = baseURLOverride || provider?.baseURL;
144
+ if (baseURL) {
145
+ clientOptions.baseURL = baseURL;
146
+ }
147
+ // Custom headers: env override takes precedence over provider defaults
148
+ const rawHeaders = process.env.PROTOAGENT_CUSTOM_HEADERS?.trim();
149
+ if (rawHeaders) {
150
+ const defaultHeaders = {};
151
+ for (const line of rawHeaders.split('\n')) {
152
+ const sep = line.indexOf(': ');
153
+ if (sep === -1)
154
+ continue;
155
+ const key = line.slice(0, sep).trim();
156
+ const value = line.slice(sep + 2).trim();
157
+ if (key && value)
158
+ defaultHeaders[key] = value;
159
+ }
160
+ if (Object.keys(defaultHeaders).length > 0) {
161
+ clientOptions.defaultHeaders = defaultHeaders;
162
+ }
163
+ }
164
+ else if (provider?.headers && Object.keys(provider.headers).length > 0) {
165
+ clientOptions.defaultHeaders = provider.headers;
142
166
  }
143
167
  return new OpenAI(clientOptions);
144
168
  }
@@ -176,7 +200,7 @@ const InlineSetup = ({ onComplete }) => {
176
200
  const [selectedProviderId, setSelectedProviderId] = useState('');
177
201
  const [selectedModelId, setSelectedModelId] = useState('');
178
202
  const [apiKeyError, setApiKeyError] = useState('');
179
- const providerItems = SUPPORTED_MODELS.flatMap((provider) => provider.models.map((model) => ({
203
+ const providerItems = getAllProviders().flatMap((provider) => provider.models.map((model) => ({
180
204
  label: `${provider.name} - ${model.name}`,
181
205
  value: `${provider.id}:::${model.id}`,
182
206
  })));
@@ -189,15 +213,16 @@ const InlineSetup = ({ onComplete }) => {
189
213
  } }) })] }));
190
214
  }
191
215
  const provider = getProvider(selectedProviderId);
192
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: "First-time setup" }), _jsxs(Text, { dimColor: true, children: ["Selected: ", provider?.name, " / ", selectedModelId] }), _jsx(Text, { children: "Enter your API key:" }), apiKeyError && _jsx(Text, { color: "red", children: apiKeyError }), _jsx(PasswordInput, { placeholder: `Paste your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: (value) => {
193
- if (value.trim().length === 0) {
216
+ const hasResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
217
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: "First-time setup" }), _jsxs(Text, { dimColor: true, children: ["Selected: ", provider?.name, " / ", selectedModelId] }), _jsx(Text, { children: hasResolvedAuth ? 'Optional API key:' : 'Enter your API key:' }), apiKeyError && _jsx(Text, { color: "red", children: apiKeyError }), _jsx(PasswordInput, { placeholder: hasResolvedAuth ? 'Press enter to keep resolved auth' : `Paste your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: (value) => {
218
+ if (value.trim().length === 0 && !hasResolvedAuth) {
194
219
  setApiKeyError('API key cannot be empty.');
195
220
  return;
196
221
  }
197
222
  const newConfig = {
198
223
  provider: selectedProviderId,
199
224
  model: selectedModelId,
200
- apiKey: value.trim(),
225
+ ...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
201
226
  };
202
227
  writeConfig(newConfig);
203
228
  onComplete(newConfig);
@@ -311,6 +336,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
311
336
  setPendingApproval({ request: req, resolve });
312
337
  });
313
338
  });
339
+ await loadRuntimeConfig();
314
340
  // Load config — if none exists, show inline setup
315
341
  const loadedConfig = readConfig();
316
342
  if (!loadedConfig) {
@@ -416,6 +442,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
416
442
  assistantMessageRef.current = null;
417
443
  try {
418
444
  const pricing = getModelPricing(config.provider, config.model);
445
+ const requestDefaults = getRequestDefaultParams(config.provider, config.model);
419
446
  // Create abort controller for this completion
420
447
  abortControllerRef.current = new AbortController();
421
448
  const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, (event) => {
@@ -548,7 +575,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
548
575
  setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
549
576
  break;
550
577
  }
551
- }, { pricing: pricing || undefined, abortSignal: abortControllerRef.current.signal, sessionId: session?.id });
578
+ }, {
579
+ pricing: pricing || undefined,
580
+ abortSignal: abortControllerRef.current.signal,
581
+ sessionId: session?.id,
582
+ requestDefaults,
583
+ });
552
584
  // Final update to ensure we have the complete message history
553
585
  setCompletionMessages(updatedMessages);
554
586
  // Update session
@@ -219,6 +219,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
219
219
  const pricing = options.pricing;
220
220
  const abortSignal = options.abortSignal;
221
221
  const sessionId = options.sessionId;
222
+ const requestDefaults = options.requestDefaults || {};
222
223
  // Note: userInput is passed for context/logging but user message should already be in messages array
223
224
  // (added by the caller in handleSubmit for immediate UI display)
224
225
  const updatedMessages = [...messages];
@@ -243,7 +244,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
243
244
  if (pricing) {
244
245
  const contextInfo = getContextInfo(updatedMessages, pricing);
245
246
  if (contextInfo.needsCompaction) {
246
- const compacted = await compactIfNeeded(client, model, updatedMessages, pricing.contextWindow, contextInfo.currentTokens);
247
+ const compacted = await compactIfNeeded(client, model, updatedMessages, pricing.contextWindow, contextInfo.currentTokens, requestDefaults, sessionId);
247
248
  // Replace messages in-place
248
249
  updatedMessages.length = 0;
249
250
  updatedMessages.push(...compacted);
@@ -287,6 +288,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
287
288
  }
288
289
  }
289
290
  const stream = await client.chat.completions.create({
291
+ ...requestDefaults,
290
292
  model,
291
293
  messages: updatedMessages,
292
294
  tools: allTools,
@@ -399,7 +401,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
399
401
  let result;
400
402
  // Handle sub-agent tool specially
401
403
  if (name === 'sub_agent') {
402
- result = await runSubAgent(client, model, args.task, args.max_iterations);
404
+ result = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults);
403
405
  }
404
406
  else {
405
407
  result = await handleToolCall(name, args, { sessionId });
@@ -7,12 +7,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  import { useState } from 'react';
8
8
  import { Box, Text } from 'ink';
9
9
  import { PasswordInput, Select } from '@inkjs/ui';
10
- import { getProvider, SUPPORTED_MODELS } from '../providers.js';
10
+ import { getAllProviders, getProvider } from '../providers.js';
11
+ import { resolveApiKey } from '../config.js';
11
12
  export const ConfigDialog = ({ currentConfig, onComplete, onCancel, }) => {
12
13
  const [step, setStep] = useState('select_provider');
13
14
  const [selectedProviderId, setSelectedProviderId] = useState(currentConfig.provider);
14
15
  const [selectedModelId, setSelectedModelId] = useState(currentConfig.model);
15
- const providerItems = SUPPORTED_MODELS.flatMap((provider) => provider.models.map((model) => ({
16
+ const providerItems = getAllProviders().flatMap((provider) => provider.models.map((model) => ({
16
17
  label: `${provider.name} - ${model.name}`,
17
18
  value: `${provider.id}:::${model.id}`,
18
19
  })));
@@ -28,12 +29,13 @@ export const ConfigDialog = ({ currentConfig, onComplete, onCancel, }) => {
28
29
  }
29
30
  // API key entry step
30
31
  const provider = getProvider(selectedProviderId);
31
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { color: "green", bold: true, children: "Confirm Configuration" }), _jsxs(Text, { dimColor: true, children: ["Provider: ", provider?.name, " / ", selectedModelId] }), _jsx(Text, { children: "Enter your API key (or leave empty to keep current/env var):" }), _jsx(PasswordInput, { placeholder: `Paste your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: (value) => {
32
+ const hasResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
33
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { color: "green", bold: true, children: "Confirm Configuration" }), _jsxs(Text, { dimColor: true, children: ["Provider: ", provider?.name, " / ", selectedModelId] }), _jsx(Text, { children: hasResolvedAuth ? 'Optional API key (leave empty to keep resolved auth):' : 'Enter your API key:' }), _jsx(PasswordInput, { placeholder: `Paste your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: (value) => {
32
34
  const finalApiKey = value.trim().length > 0 ? value.trim() : currentConfig.apiKey;
33
35
  const newConfig = {
34
36
  provider: selectedProviderId,
35
37
  model: selectedModelId,
36
- apiKey: finalApiKey,
38
+ ...(finalApiKey?.trim() ? { apiKey: finalApiKey.trim() } : {}),
37
39
  };
38
40
  onComplete(newConfig);
39
41
  } })] }));
package/dist/config.js CHANGED
@@ -5,7 +5,8 @@ import os from 'node:os';
5
5
  import { useState, useEffect } from 'react';
6
6
  import { Box, Text } from 'ink';
7
7
  import { Select, TextInput, PasswordInput } from '@inkjs/ui';
8
- import { SUPPORTED_MODELS, getProvider } from './providers.js';
8
+ import { loadRuntimeConfig } from './runtime-config.js';
9
+ import { getAllProviders, getProvider } from './providers.js';
9
10
  const CONFIG_DIR_MODE = 0o700;
10
11
  const CONFIG_FILE_MODE = 0o600;
11
12
  function hardenPermissions(targetPath, mode) {
@@ -19,11 +20,34 @@ export function resolveApiKey(config) {
19
20
  return directApiKey;
20
21
  }
21
22
  const provider = getProvider(config.provider);
23
+ if (provider?.apiKeyEnvVar) {
24
+ const providerEnvOverride = process.env[provider.apiKeyEnvVar]?.trim();
25
+ if (providerEnvOverride) {
26
+ return providerEnvOverride;
27
+ }
28
+ }
29
+ const envOverride = process.env.PROTOAGENT_API_KEY?.trim();
30
+ if (envOverride) {
31
+ return envOverride;
32
+ }
33
+ const providerApiKey = provider?.apiKey?.trim();
34
+ if (providerApiKey) {
35
+ return providerApiKey;
36
+ }
37
+ // Fallback for Cloudflare Gateway or other custom header setups
38
+ if (process.env.PROTOAGENT_CUSTOM_HEADERS) {
39
+ return 'none';
40
+ }
22
41
  if (!provider?.apiKeyEnvVar) {
42
+ if (provider?.headers && Object.keys(provider.headers).length > 0) {
43
+ return 'none';
44
+ }
23
45
  return null;
24
46
  }
25
- const envApiKey = process.env[provider.apiKeyEnvVar]?.trim();
26
- return envApiKey || null;
47
+ if (provider?.headers && Object.keys(provider.headers).length > 0) {
48
+ return 'none';
49
+ }
50
+ return null;
27
51
  }
28
52
  export const getConfigDirectory = () => {
29
53
  const homeDir = os.homedir();
@@ -51,8 +75,8 @@ export const readConfig = () => {
51
75
  // Handle legacy format: { provider, model, credentials: { KEY: "..." } }
52
76
  let apiKey = raw.apiKey;
53
77
  if (!apiKey && raw.credentials && typeof raw.credentials === 'object') {
54
- const provider = SUPPORTED_MODELS.find((p) => p.id === raw.provider);
55
- if (provider) {
78
+ const provider = getAllProviders().find((p) => p.id === raw.provider);
79
+ if (provider?.apiKeyEnvVar) {
56
80
  apiKey = raw.credentials[provider.apiKeyEnvVar];
57
81
  }
58
82
  // Fallback: grab the first non-empty value
@@ -89,14 +113,21 @@ export const writeConfig = (config) => {
89
113
  };
90
114
  export const InitialLoading = ({ setExistingConfig, setStep }) => {
91
115
  useEffect(() => {
92
- const config = readConfig();
93
- if (config) {
94
- setExistingConfig(config);
95
- setStep(1);
96
- }
97
- else {
116
+ loadRuntimeConfig()
117
+ .then(() => {
118
+ const config = readConfig();
119
+ if (config) {
120
+ setExistingConfig(config);
121
+ setStep(1);
122
+ }
123
+ else {
124
+ setStep(2);
125
+ }
126
+ })
127
+ .catch((error) => {
128
+ console.error('Error loading runtime config:', error);
98
129
  setStep(2);
99
- }
130
+ });
100
131
  }, []);
101
132
  return _jsx(Text, { children: "Loading configuration..." });
102
133
  };
@@ -114,7 +145,7 @@ export const ResetPrompt = ({ existingConfig, setStep, setConfigWritten }) => {
114
145
  } })] }));
115
146
  };
116
147
  export const ModelSelection = ({ setSelectedProviderId, setSelectedModelId, setStep, }) => {
117
- const items = SUPPORTED_MODELS.flatMap((provider) => provider.models.map((model) => ({
148
+ const items = getAllProviders().flatMap((provider) => provider.models.map((model) => ({
118
149
  label: `${provider.name} - ${model.name}`,
119
150
  value: `${provider.id}:::${model.id}`,
120
151
  })));
@@ -129,21 +160,22 @@ export const ModelSelection = ({ setSelectedProviderId, setSelectedModelId, setS
129
160
  export const ApiKeyInput = ({ selectedProviderId, selectedModelId, setStep, setConfigWritten, }) => {
130
161
  const [errorMessage, setErrorMessage] = useState('');
131
162
  const provider = getProvider(selectedProviderId);
163
+ const canUseResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
132
164
  const handleApiKeySubmit = (value) => {
133
- if (value.trim().length === 0) {
165
+ if (value.trim().length === 0 && !canUseResolvedAuth) {
134
166
  setErrorMessage('API key cannot be empty.');
135
167
  return;
136
168
  }
137
169
  const newConfig = {
138
170
  provider: selectedProviderId,
139
171
  model: selectedModelId,
140
- apiKey: value.trim(),
172
+ ...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
141
173
  };
142
174
  writeConfig(newConfig);
143
175
  setConfigWritten(true);
144
176
  setStep(4);
145
177
  };
146
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Enter API Key for ", provider?.name || selectedProviderId, ":"] }), errorMessage && _jsx(Text, { color: "red", children: errorMessage }), _jsx(PasswordInput, { placeholder: `Enter your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: handleApiKeySubmit })] }));
178
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [canUseResolvedAuth ? 'Optional API Key' : 'Enter API Key', " for ", provider?.name || selectedProviderId, ":"] }), provider?.headers && Object.keys(provider.headers).length > 0 && (_jsx(Text, { dimColor: true, children: "This provider can authenticate with configured headers or environment variables." })), errorMessage && _jsx(Text, { color: "red", children: errorMessage }), _jsx(PasswordInput, { placeholder: canUseResolvedAuth ? 'Press enter to keep resolved auth' : `Enter your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: handleApiKeySubmit })] }));
147
179
  };
148
180
  export const ConfigResult = ({ configWritten }) => {
149
181
  return (_jsxs(Box, { flexDirection: "column", children: [configWritten ? (_jsx(Text, { color: "green", children: "Configuration saved successfully!" })) : (_jsx(Text, { color: "yellow", children: "Configuration not changed." })), _jsx(Text, { children: "You can now run ProtoAgent." })] }));
package/dist/mcp.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Uses the official @modelcontextprotocol/sdk to connect to MCP servers
5
5
  * over both stdio (spawned processes) and HTTP transports.
6
6
  *
7
- * Configuration in `.protoagent/mcp.json`:
7
+ * Configuration in `protoagent.jsonc` under `mcp.servers`:
8
8
  * {
9
9
  * "servers": {
10
10
  * "my-stdio-server": {
@@ -23,11 +23,10 @@
23
23
  * Stdio servers are spawned as child processes communicating over stdin/stdout.
24
24
  * HTTP servers connect to a running server via HTTP POST/GET with SSE streaming.
25
25
  */
26
- import fs from 'node:fs/promises';
27
- import path from 'node:path';
28
26
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
29
27
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
30
28
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
29
+ import { loadRuntimeConfig, getRuntimeConfig } from './runtime-config.js';
31
30
  import { logger } from './utils/logger.js';
32
31
  import { registerDynamicTool, registerDynamicHandler } from './tools/index.js';
33
32
  const connections = new Map();
@@ -38,7 +37,11 @@ async function connectStdioServer(serverName, config) {
38
37
  const transport = new StdioClientTransport({
39
38
  command: config.command,
40
39
  args: config.args || [],
41
- env: config.env || {},
40
+ env: {
41
+ ...process.env,
42
+ ...(config.env || {}),
43
+ },
44
+ cwd: config.cwd,
42
45
  });
43
46
  const client = new Client({
44
47
  name: 'protoagent',
@@ -57,7 +60,9 @@ async function connectStdioServer(serverName, config) {
57
60
  * Create an MCP client connection for an HTTP server.
58
61
  */
59
62
  async function connectHttpServer(serverName, config) {
60
- const transport = new StreamableHTTPClientTransport(new URL(config.url));
63
+ const transport = new StreamableHTTPClientTransport(new URL(config.url), {
64
+ requestInit: config.headers ? { headers: config.headers } : undefined,
65
+ });
61
66
  const client = new Client({
62
67
  name: 'protoagent',
63
68
  version: '0.0.1',
@@ -92,7 +97,7 @@ async function registerMcpTools(conn) {
92
97
  registerDynamicHandler(toolName, async (args) => {
93
98
  const result = await conn.client.callTool({
94
99
  name: tool.name,
95
- arguments: args,
100
+ arguments: (args && typeof args === 'object' ? args : {}),
96
101
  });
97
102
  // MCP tool results are arrays of content blocks
98
103
  if (Array.isArray(result.content)) {
@@ -117,20 +122,16 @@ async function registerMcpTools(conn) {
117
122
  * Registers their tools in the dynamic tool registry.
118
123
  */
119
124
  export async function initializeMcp() {
120
- const configPath = path.join(process.cwd(), '.protoagent', 'mcp.json');
121
- let config;
122
- try {
123
- const content = await fs.readFile(configPath, 'utf8');
124
- config = JSON.parse(content);
125
- }
126
- catch {
127
- // No MCP config — that's fine, most projects won't have one
125
+ await loadRuntimeConfig();
126
+ const servers = getRuntimeConfig().mcp?.servers || {};
127
+ if (Object.keys(servers).length === 0)
128
128
  return;
129
- }
130
- if (!config.servers || Object.keys(config.servers).length === 0)
131
- return;
132
- logger.info(`Loading MCP servers from ${configPath}`);
133
- for (const [name, serverConfig] of Object.entries(config.servers)) {
129
+ logger.info('Loading MCP servers from merged runtime config');
130
+ for (const [name, serverConfig] of Object.entries(servers)) {
131
+ if (serverConfig.enabled === false) {
132
+ logger.debug(`Skipping disabled MCP server: ${name}`);
133
+ continue;
134
+ }
134
135
  try {
135
136
  let conn;
136
137
  if (serverConfig.type === 'stdio') {
package/dist/providers.js CHANGED
@@ -1,37 +1,19 @@
1
1
  /**
2
- * Model and provider definitions.
2
+ * Provider and model registry.
3
3
  *
4
- * All providers use the OpenAI SDK via compatible endpoints.
5
- * To add a new provider, add an entry here with its baseURL
6
- * and API key env var name, and any models it supports.
4
+ * Built-in providers are declared in source and merged with runtime overrides
5
+ * from `protoagent.jsonc`.
7
6
  */
8
- export const SUPPORTED_MODELS = [
7
+ import { getRuntimeConfig } from './runtime-config.js';
8
+ export const BUILTIN_PROVIDERS = [
9
9
  {
10
10
  id: 'openai',
11
11
  name: 'OpenAI',
12
12
  apiKeyEnvVar: 'OPENAI_API_KEY',
13
13
  models: [
14
- {
15
- id: 'gpt-5.2',
16
- name: 'GPT-5.2',
17
- contextWindow: 200_000,
18
- pricingPerMillionInput: 6.00,
19
- pricingPerMillionOutput: 24.00,
20
- },
21
- {
22
- id: 'gpt-5-mini',
23
- name: 'GPT-5 Mini',
24
- contextWindow: 200_000,
25
- pricingPerMillionInput: 0.15,
26
- pricingPerMillionOutput: 0.60,
27
- },
28
- {
29
- id: 'gpt-4.1',
30
- name: 'GPT-4.1',
31
- contextWindow: 128_000,
32
- pricingPerMillionInput: 2.50,
33
- pricingPerMillionOutput: 10.00,
34
- },
14
+ { id: 'gpt-5.2', name: 'GPT-5.2', contextWindow: 200_000, pricingPerMillionInput: 6.0, pricingPerMillionOutput: 24.0 },
15
+ { id: 'gpt-5-mini', name: 'GPT-5 Mini', contextWindow: 200_000, pricingPerMillionInput: 0.15, pricingPerMillionOutput: 0.6 },
16
+ { id: 'gpt-4.1', name: 'GPT-4.1', contextWindow: 128_000, pricingPerMillionInput: 2.5, pricingPerMillionOutput: 10.0 },
35
17
  ],
36
18
  },
37
19
  {
@@ -40,27 +22,9 @@ export const SUPPORTED_MODELS = [
40
22
  baseURL: 'https://api.anthropic.com/v1/',
41
23
  apiKeyEnvVar: 'ANTHROPIC_API_KEY',
42
24
  models: [
43
- {
44
- id: 'claude-opus-4-6',
45
- name: 'Claude Opus 4.6',
46
- contextWindow: 200_000,
47
- pricingPerMillionInput: 5.00,
48
- pricingPerMillionOutput: 25.00,
49
- },
50
- {
51
- id: 'claude-sonnet-4-6',
52
- name: 'Claude Sonnet 4.6',
53
- contextWindow: 200_000,
54
- pricingPerMillionInput: 3.00,
55
- pricingPerMillionOutput: 15.00,
56
- },
57
- {
58
- id: 'claude-haiku-4-5',
59
- name: 'Claude Haiku 4.5',
60
- contextWindow: 200_000,
61
- pricingPerMillionInput: 1.00,
62
- pricingPerMillionOutput: 5.00,
63
- },
25
+ { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', contextWindow: 200_000, pricingPerMillionInput: 5.0, pricingPerMillionOutput: 25.0 },
26
+ { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', contextWindow: 200_000, pricingPerMillionInput: 3.0, pricingPerMillionOutput: 15.0 },
27
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', contextWindow: 200_000, pricingPerMillionInput: 1.0, pricingPerMillionOutput: 5.0 },
64
28
  ],
65
29
  },
66
30
  {
@@ -69,34 +33,10 @@ export const SUPPORTED_MODELS = [
69
33
  baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/',
70
34
  apiKeyEnvVar: 'GEMINI_API_KEY',
71
35
  models: [
72
- {
73
- id: 'gemini-3-flash-preview',
74
- name: 'Gemini 3 Flash (Preview)',
75
- contextWindow: 1_000_000,
76
- pricingPerMillionInput: 0.075,
77
- pricingPerMillionOutput: 0.30,
78
- },
79
- {
80
- id: 'gemini-3-pro-preview',
81
- name: 'Gemini 3 Pro (Preview)',
82
- contextWindow: 1_000_000,
83
- pricingPerMillionInput: 1.25,
84
- pricingPerMillionOutput: 10.00,
85
- },
86
- {
87
- id: 'gemini-2.5-flash',
88
- name: 'Gemini 2.5 Flash',
89
- contextWindow: 1_000_000,
90
- pricingPerMillionInput: 0.075,
91
- pricingPerMillionOutput: 0.30,
92
- },
93
- {
94
- id: 'gemini-2.5-pro',
95
- name: 'Gemini 2.5 Pro',
96
- contextWindow: 1_000_000,
97
- pricingPerMillionInput: 1.25,
98
- pricingPerMillionOutput: 10.00,
99
- },
36
+ { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash (Preview)', contextWindow: 1_000_000, pricingPerMillionInput: 0.075, pricingPerMillionOutput: 0.3 },
37
+ { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro (Preview)', contextWindow: 1_000_000, pricingPerMillionInput: 1.25, pricingPerMillionOutput: 10.0 },
38
+ { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', contextWindow: 1_000_000, pricingPerMillionInput: 0.075, pricingPerMillionOutput: 0.3 },
39
+ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', contextWindow: 1_000_000, pricingPerMillionInput: 1.25, pricingPerMillionOutput: 10.0 },
100
40
  ],
101
41
  },
102
42
  {
@@ -105,26 +45,63 @@ export const SUPPORTED_MODELS = [
105
45
  baseURL: 'https://api.cerebras.ai/v1',
106
46
  apiKeyEnvVar: 'CEREBRAS_API_KEY',
107
47
  models: [
108
- {
109
- id: 'llama-4-scout-17b-16e-instruct',
110
- name: 'Llama 4 Scout 17B',
111
- contextWindow: 128_000,
112
- pricingPerMillionInput: 0.00,
113
- pricingPerMillionOutput: 0.00,
114
- },
48
+ { id: 'llama-4-scout-17b-16e-instruct', name: 'Llama 4 Scout 17B', contextWindow: 128_000, pricingPerMillionInput: 0.0, pricingPerMillionOutput: 0.0 },
115
49
  ],
116
50
  },
117
51
  ];
118
- /** Find a provider by ID. */
52
+ function sanitizeDefaultParams(defaultParams) {
53
+ if (!defaultParams || Object.keys(defaultParams).length === 0)
54
+ return undefined;
55
+ return defaultParams;
56
+ }
57
+ function toProviderMap(providers) {
58
+ return new Map(providers.map((provider) => [provider.id, provider]));
59
+ }
60
+ function mergeModelLists(baseModels, overrideModels) {
61
+ const merged = new Map(baseModels.map((model) => [model.id, model]));
62
+ for (const [modelId, override] of Object.entries(overrideModels || {})) {
63
+ const current = merged.get(modelId);
64
+ merged.set(modelId, {
65
+ id: modelId,
66
+ name: override.name ?? current?.name ?? modelId,
67
+ contextWindow: override.contextWindow ?? current?.contextWindow ?? 0,
68
+ pricingPerMillionInput: override.inputPricePerMillion ?? current?.pricingPerMillionInput ?? 0,
69
+ pricingPerMillionOutput: override.outputPricePerMillion ?? current?.pricingPerMillionOutput ?? 0,
70
+ defaultParams: sanitizeDefaultParams({
71
+ ...(current?.defaultParams || {}),
72
+ ...(override.defaultParams || {}),
73
+ }),
74
+ });
75
+ }
76
+ return Array.from(merged.values());
77
+ }
78
+ export function getAllProviders() {
79
+ const runtimeProviders = getRuntimeConfig().providers || {};
80
+ const mergedProviders = toProviderMap(BUILTIN_PROVIDERS);
81
+ for (const [providerId, providerConfig] of Object.entries(runtimeProviders)) {
82
+ const current = mergedProviders.get(providerId);
83
+ mergedProviders.set(providerId, {
84
+ id: providerId,
85
+ name: providerConfig.name ?? current?.name ?? providerId,
86
+ baseURL: providerConfig.baseURL ?? current?.baseURL,
87
+ apiKey: providerConfig.apiKey ?? current?.apiKey,
88
+ apiKeyEnvVar: providerConfig.apiKeyEnvVar ?? current?.apiKeyEnvVar,
89
+ headers: providerConfig.headers ?? current?.headers,
90
+ defaultParams: sanitizeDefaultParams({
91
+ ...(current?.defaultParams || {}),
92
+ ...(providerConfig.defaultParams || {}),
93
+ }),
94
+ models: mergeModelLists(current?.models || [], providerConfig.models),
95
+ });
96
+ }
97
+ return Array.from(mergedProviders.values());
98
+ }
119
99
  export function getProvider(providerId) {
120
- return SUPPORTED_MODELS.find((p) => p.id === providerId);
100
+ return getAllProviders().find((provider) => provider.id === providerId);
121
101
  }
122
- /** Find a model's details by provider and model ID. */
123
102
  export function getModelDetails(providerId, modelId) {
124
- const provider = getProvider(providerId);
125
- return provider?.models.find((m) => m.id === modelId);
103
+ return getProvider(providerId)?.models.find((model) => model.id === modelId);
126
104
  }
127
- /** Get model pricing in per-token format (for cost-tracker). */
128
105
  export function getModelPricing(providerId, modelId) {
129
106
  const details = getModelDetails(providerId, modelId);
130
107
  if (!details)
@@ -135,3 +112,11 @@ export function getModelPricing(providerId, modelId) {
135
112
  contextWindow: details.contextWindow,
136
113
  };
137
114
  }
115
+ export function getRequestDefaultParams(providerId, modelId) {
116
+ const provider = getProvider(providerId);
117
+ const model = getModelDetails(providerId, modelId);
118
+ return {
119
+ ...(provider?.defaultParams || {}),
120
+ ...(model?.defaultParams || {}),
121
+ };
122
+ }
@@ -0,0 +1,175 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { parse, printParseErrorCode } from 'jsonc-parser';
5
+ import { logger } from './utils/logger.js';
6
+ const RESERVED_DEFAULT_PARAM_KEYS = new Set([
7
+ 'model',
8
+ 'messages',
9
+ 'tools',
10
+ 'tool_choice',
11
+ 'stream',
12
+ 'stream_options',
13
+ ]);
14
+ const DEFAULT_RUNTIME_CONFIG = {
15
+ providers: {},
16
+ mcp: { servers: {} },
17
+ };
18
+ let runtimeConfigCache = null;
19
+ function getRuntimeConfigPaths() {
20
+ const homeDir = os.homedir();
21
+ return [
22
+ path.join(homeDir, '.config', 'protoagent', 'protoagent.jsonc'),
23
+ path.join(homeDir, '.protoagent', 'protoagent.jsonc'),
24
+ path.join(process.cwd(), '.protoagent', 'protoagent.jsonc'),
25
+ ];
26
+ }
27
+ function isPlainObject(value) {
28
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
29
+ }
30
+ function interpolateString(value, sourcePath) {
31
+ return value.replace(/\$\{([A-Z0-9_]+)\}/gi, (_match, envVar) => {
32
+ const resolved = process.env[envVar];
33
+ if (resolved === undefined) {
34
+ logger.warn(`Missing environment variable ${envVar} while loading ${sourcePath}`);
35
+ return '';
36
+ }
37
+ return resolved;
38
+ });
39
+ }
40
+ function interpolateValue(value, sourcePath) {
41
+ if (typeof value === 'string') {
42
+ return interpolateString(value, sourcePath);
43
+ }
44
+ if (Array.isArray(value)) {
45
+ return value.map((entry) => interpolateValue(entry, sourcePath));
46
+ }
47
+ if (isPlainObject(value)) {
48
+ const next = {};
49
+ for (const [key, entry] of Object.entries(value)) {
50
+ const interpolated = interpolateValue(entry, sourcePath);
51
+ if (key === 'headers' && isPlainObject(interpolated)) {
52
+ const filtered = Object.fromEntries(Object.entries(interpolated).filter(([, headerValue]) => typeof headerValue !== 'string' || headerValue.length > 0));
53
+ next[key] = filtered;
54
+ continue;
55
+ }
56
+ next[key] = interpolated;
57
+ }
58
+ return next;
59
+ }
60
+ return value;
61
+ }
62
+ function sanitizeDefaultParamsInConfig(config) {
63
+ const nextProviders = Object.fromEntries(Object.entries(config.providers || {}).map(([providerId, provider]) => {
64
+ const providerDefaultParams = Object.fromEntries(Object.entries(provider.defaultParams || {}).filter(([key]) => {
65
+ const allowed = !RESERVED_DEFAULT_PARAM_KEYS.has(key);
66
+ if (!allowed) {
67
+ logger.warn(`Ignoring reserved provider default param '${key}' for provider ${providerId}`);
68
+ }
69
+ return allowed;
70
+ }));
71
+ const nextModels = Object.fromEntries(Object.entries(provider.models || {}).map(([modelId, model]) => {
72
+ const modelDefaultParams = Object.fromEntries(Object.entries(model.defaultParams || {}).filter(([key]) => {
73
+ const allowed = !RESERVED_DEFAULT_PARAM_KEYS.has(key);
74
+ if (!allowed) {
75
+ logger.warn(`Ignoring reserved model default param '${key}' for model ${providerId}/${modelId}`);
76
+ }
77
+ return allowed;
78
+ }));
79
+ return [
80
+ modelId,
81
+ {
82
+ ...model,
83
+ ...(Object.keys(modelDefaultParams).length > 0 ? { defaultParams: modelDefaultParams } : {}),
84
+ },
85
+ ];
86
+ }));
87
+ return [
88
+ providerId,
89
+ {
90
+ ...provider,
91
+ ...(Object.keys(providerDefaultParams).length > 0 ? { defaultParams: providerDefaultParams } : {}),
92
+ models: nextModels,
93
+ },
94
+ ];
95
+ }));
96
+ return {
97
+ ...config,
98
+ providers: nextProviders,
99
+ };
100
+ }
101
+ function mergeRuntimeConfig(base, overlay) {
102
+ const mergedProviders = {
103
+ ...(base.providers || {}),
104
+ };
105
+ for (const [providerId, providerConfig] of Object.entries(overlay.providers || {})) {
106
+ const currentProvider = mergedProviders[providerId] || {};
107
+ mergedProviders[providerId] = {
108
+ ...currentProvider,
109
+ ...providerConfig,
110
+ models: {
111
+ ...(currentProvider.models || {}),
112
+ ...(providerConfig.models || {}),
113
+ },
114
+ };
115
+ }
116
+ const mergedServers = {
117
+ ...(base.mcp?.servers || {}),
118
+ };
119
+ for (const [serverName, serverConfig] of Object.entries(overlay.mcp?.servers || {})) {
120
+ const currentServer = mergedServers[serverName];
121
+ mergedServers[serverName] = currentServer && isPlainObject(currentServer)
122
+ ? { ...currentServer, ...serverConfig }
123
+ : serverConfig;
124
+ }
125
+ return {
126
+ providers: mergedProviders,
127
+ mcp: {
128
+ servers: mergedServers,
129
+ },
130
+ };
131
+ }
132
+ async function readRuntimeConfigFile(configPath) {
133
+ try {
134
+ const content = await fs.readFile(configPath, 'utf8');
135
+ const errors = [];
136
+ const parsed = parse(content, errors, { allowTrailingComma: true, disallowComments: false });
137
+ if (errors.length > 0) {
138
+ const details = errors
139
+ .map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
140
+ .join(', ');
141
+ throw new Error(`Failed to parse ${configPath}: ${details}`);
142
+ }
143
+ if (!isPlainObject(parsed)) {
144
+ throw new Error(`Failed to parse ${configPath}: top-level value must be an object`);
145
+ }
146
+ return sanitizeDefaultParamsInConfig(interpolateValue(parsed, configPath));
147
+ }
148
+ catch (error) {
149
+ if (error?.code === 'ENOENT') {
150
+ return null;
151
+ }
152
+ throw error;
153
+ }
154
+ }
155
+ export async function loadRuntimeConfig(forceReload = false) {
156
+ if (!forceReload && runtimeConfigCache) {
157
+ return runtimeConfigCache;
158
+ }
159
+ let merged = DEFAULT_RUNTIME_CONFIG;
160
+ for (const configPath of getRuntimeConfigPaths()) {
161
+ const fileConfig = await readRuntimeConfigFile(configPath);
162
+ if (!fileConfig)
163
+ continue;
164
+ logger.debug(`Loaded runtime config`, { path: configPath });
165
+ merged = mergeRuntimeConfig(merged, fileConfig);
166
+ }
167
+ runtimeConfigCache = merged;
168
+ return merged;
169
+ }
170
+ export function getRuntimeConfig() {
171
+ return runtimeConfigCache || DEFAULT_RUNTIME_CONFIG;
172
+ }
173
+ export function resetRuntimeConfigForTests() {
174
+ runtimeConfigCache = null;
175
+ }
package/dist/sub-agent.js CHANGED
@@ -39,7 +39,7 @@ export const subAgentTool = {
39
39
  * Run a sub-agent with its own isolated conversation.
40
40
  * Returns the sub-agent's final text response.
41
41
  */
42
- export async function runSubAgent(client, model, task, maxIterations = 30) {
42
+ export async function runSubAgent(client, model, task, maxIterations = 30, requestDefaults = {}) {
43
43
  const op = logger.startOperation('sub-agent');
44
44
  const subAgentSessionId = `sub-agent-${crypto.randomUUID()}`;
45
45
  const systemPrompt = await generateSystemPrompt();
@@ -57,6 +57,7 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
57
57
  try {
58
58
  for (let i = 0; i < maxIterations; i++) {
59
59
  const response = await client.chat.completions.create({
60
+ ...requestDefaults,
60
61
  model,
61
62
  messages,
62
63
  tools: getAllTools(),
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { estimateConversationTokens } from './cost-tracker.js';
10
10
  import { logger } from './logger.js';
11
+ import { getTodosForSession } from '../tools/todo.js';
11
12
  const RECENT_MESSAGES_TO_KEEP = 5;
12
13
  function isProtectedSkillMessage(message) {
13
14
  return message.role === 'tool' && typeof message.content === 'string' && message.content.includes('<skill_content ');
@@ -29,20 +30,20 @@ Be thorough but concise. Do not lose any information that would be needed to con
29
30
  * Compact a conversation if it exceeds the context window threshold.
30
31
  * Returns the original messages if compaction isn't needed or fails.
31
32
  */
32
- export async function compactIfNeeded(client, model, messages, contextWindow, currentTokens) {
33
+ export async function compactIfNeeded(client, model, messages, contextWindow, currentTokens, requestDefaults = {}, sessionId) {
33
34
  const utilisation = (currentTokens / contextWindow) * 100;
34
35
  if (utilisation < 90)
35
36
  return messages;
36
37
  logger.info(`Compacting conversation (${utilisation.toFixed(1)}% of context window used)`);
37
38
  try {
38
- return await compactConversation(client, model, messages);
39
+ return await compactConversation(client, model, messages, requestDefaults, sessionId);
39
40
  }
40
41
  catch (err) {
41
42
  logger.error(`Compaction failed, continuing with original messages: ${err}`);
42
43
  return messages;
43
44
  }
44
45
  }
45
- async function compactConversation(client, model, messages) {
46
+ async function compactConversation(client, model, messages, _requestDefaults, sessionId) {
46
47
  // Separate system message, history to compress, and recent messages
47
48
  const systemMessage = messages[0];
48
49
  const recentMessages = messages.slice(-RECENT_MESSAGES_TO_KEEP);
@@ -54,13 +55,17 @@ async function compactConversation(client, model, messages) {
54
55
  return messages;
55
56
  }
56
57
  // Build compression request
58
+ const activeTodos = getTodosForSession(sessionId);
59
+ const todoReminder = activeTodos.length > 0
60
+ ? `\n\nActive TODOs:\n${activeTodos.map((todo) => `- [${todo.status}] ${todo.content}`).join('\n')}\n\nThe agent must not stop until the TODO list is fully complete. Preserve that instruction in the summary if work remains.`
61
+ : '';
57
62
  const compressionMessages = [
58
63
  { role: 'system', content: COMPRESSION_PROMPT },
59
64
  {
60
65
  role: 'user',
61
66
  content: `Here is the conversation history to compress:\n\n${historyToCompress
62
67
  .map((m) => `[${m.role}]: ${m.content || JSON.stringify(m.tool_calls || '')}`)
63
- .join('\n\n')}`,
68
+ .join('\n\n')}${todoReminder}`,
64
69
  },
65
70
  ];
66
71
  const response = await client.chat.completions.create({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protoagent",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -30,6 +30,7 @@
30
30
  "html-to-text": "^9.0.5",
31
31
  "ink": "^6.7.0",
32
32
  "ink-big-text": "^2.0.0",
33
+ "jsonc-parser": "^3.3.1",
33
34
  "openai": "^5.23.1",
34
35
  "react": "^19.1.1",
35
36
  "turndown": "^7.2.2",