protoagent 0.1.2 → 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';
@@ -143,7 +144,7 @@ function buildClient(config) {
143
144
  if (baseURL) {
144
145
  clientOptions.baseURL = baseURL;
145
146
  }
146
- // Custom headers: parse PROTOAGENT_CUSTOM_HEADERS (newline-separated "Key: Value" pairs)
147
+ // Custom headers: env override takes precedence over provider defaults
147
148
  const rawHeaders = process.env.PROTOAGENT_CUSTOM_HEADERS?.trim();
148
149
  if (rawHeaders) {
149
150
  const defaultHeaders = {};
@@ -160,6 +161,9 @@ function buildClient(config) {
160
161
  clientOptions.defaultHeaders = defaultHeaders;
161
162
  }
162
163
  }
164
+ else if (provider?.headers && Object.keys(provider.headers).length > 0) {
165
+ clientOptions.defaultHeaders = provider.headers;
166
+ }
163
167
  return new OpenAI(clientOptions);
164
168
  }
165
169
  // ─── Sub-components ───
@@ -196,7 +200,7 @@ const InlineSetup = ({ onComplete }) => {
196
200
  const [selectedProviderId, setSelectedProviderId] = useState('');
197
201
  const [selectedModelId, setSelectedModelId] = useState('');
198
202
  const [apiKeyError, setApiKeyError] = useState('');
199
- const providerItems = SUPPORTED_MODELS.flatMap((provider) => provider.models.map((model) => ({
203
+ const providerItems = getAllProviders().flatMap((provider) => provider.models.map((model) => ({
200
204
  label: `${provider.name} - ${model.name}`,
201
205
  value: `${provider.id}:::${model.id}`,
202
206
  })));
@@ -209,15 +213,16 @@ const InlineSetup = ({ onComplete }) => {
209
213
  } }) })] }));
210
214
  }
211
215
  const provider = getProvider(selectedProviderId);
212
- 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) => {
213
- 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) {
214
219
  setApiKeyError('API key cannot be empty.');
215
220
  return;
216
221
  }
217
222
  const newConfig = {
218
223
  provider: selectedProviderId,
219
224
  model: selectedModelId,
220
- apiKey: value.trim(),
225
+ ...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
221
226
  };
222
227
  writeConfig(newConfig);
223
228
  onComplete(newConfig);
@@ -331,6 +336,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
331
336
  setPendingApproval({ request: req, resolve });
332
337
  });
333
338
  });
339
+ await loadRuntimeConfig();
334
340
  // Load config — if none exists, show inline setup
335
341
  const loadedConfig = readConfig();
336
342
  if (!loadedConfig) {
@@ -436,6 +442,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
436
442
  assistantMessageRef.current = null;
437
443
  try {
438
444
  const pricing = getModelPricing(config.provider, config.model);
445
+ const requestDefaults = getRequestDefaultParams(config.provider, config.model);
439
446
  // Create abort controller for this completion
440
447
  abortControllerRef.current = new AbortController();
441
448
  const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, (event) => {
@@ -568,7 +575,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
568
575
  setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
569
576
  break;
570
577
  }
571
- }, { 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
+ });
572
584
  // Final update to ensure we have the complete message history
573
585
  setCompletionMessages(updatedMessages);
574
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) {
@@ -18,16 +19,35 @@ export function resolveApiKey(config) {
18
19
  if (directApiKey) {
19
20
  return directApiKey;
20
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
+ }
21
37
  // Fallback for Cloudflare Gateway or other custom header setups
22
38
  if (process.env.PROTOAGENT_CUSTOM_HEADERS) {
23
39
  return 'none';
24
40
  }
25
- const provider = getProvider(config.provider);
26
41
  if (!provider?.apiKeyEnvVar) {
42
+ if (provider?.headers && Object.keys(provider.headers).length > 0) {
43
+ return 'none';
44
+ }
27
45
  return null;
28
46
  }
29
- const envApiKey = process.env[provider.apiKeyEnvVar]?.trim();
30
- return envApiKey || null;
47
+ if (provider?.headers && Object.keys(provider.headers).length > 0) {
48
+ return 'none';
49
+ }
50
+ return null;
31
51
  }
32
52
  export const getConfigDirectory = () => {
33
53
  const homeDir = os.homedir();
@@ -55,8 +75,8 @@ export const readConfig = () => {
55
75
  // Handle legacy format: { provider, model, credentials: { KEY: "..." } }
56
76
  let apiKey = raw.apiKey;
57
77
  if (!apiKey && raw.credentials && typeof raw.credentials === 'object') {
58
- const provider = SUPPORTED_MODELS.find((p) => p.id === raw.provider);
59
- if (provider) {
78
+ const provider = getAllProviders().find((p) => p.id === raw.provider);
79
+ if (provider?.apiKeyEnvVar) {
60
80
  apiKey = raw.credentials[provider.apiKeyEnvVar];
61
81
  }
62
82
  // Fallback: grab the first non-empty value
@@ -93,14 +113,21 @@ export const writeConfig = (config) => {
93
113
  };
94
114
  export const InitialLoading = ({ setExistingConfig, setStep }) => {
95
115
  useEffect(() => {
96
- const config = readConfig();
97
- if (config) {
98
- setExistingConfig(config);
99
- setStep(1);
100
- }
101
- 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);
102
129
  setStep(2);
103
- }
130
+ });
104
131
  }, []);
105
132
  return _jsx(Text, { children: "Loading configuration..." });
106
133
  };
@@ -118,7 +145,7 @@ export const ResetPrompt = ({ existingConfig, setStep, setConfigWritten }) => {
118
145
  } })] }));
119
146
  };
120
147
  export const ModelSelection = ({ setSelectedProviderId, setSelectedModelId, setStep, }) => {
121
- const items = SUPPORTED_MODELS.flatMap((provider) => provider.models.map((model) => ({
148
+ const items = getAllProviders().flatMap((provider) => provider.models.map((model) => ({
122
149
  label: `${provider.name} - ${model.name}`,
123
150
  value: `${provider.id}:::${model.id}`,
124
151
  })));
@@ -133,21 +160,22 @@ export const ModelSelection = ({ setSelectedProviderId, setSelectedModelId, setS
133
160
  export const ApiKeyInput = ({ selectedProviderId, selectedModelId, setStep, setConfigWritten, }) => {
134
161
  const [errorMessage, setErrorMessage] = useState('');
135
162
  const provider = getProvider(selectedProviderId);
163
+ const canUseResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
136
164
  const handleApiKeySubmit = (value) => {
137
- if (value.trim().length === 0) {
165
+ if (value.trim().length === 0 && !canUseResolvedAuth) {
138
166
  setErrorMessage('API key cannot be empty.');
139
167
  return;
140
168
  }
141
169
  const newConfig = {
142
170
  provider: selectedProviderId,
143
171
  model: selectedModelId,
144
- apiKey: value.trim(),
172
+ ...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
145
173
  };
146
174
  writeConfig(newConfig);
147
175
  setConfigWritten(true);
148
176
  setStep(4);
149
177
  };
150
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Enter API Key for ", provider?.name || selectedProviderId, ":"] }), selectedProviderId === 'cloudflare-gateway' && (_jsx(Text, { dimColor: true, children: "For Cloudflare AI Gateway, enter any placeholder (e.g. 'none'). Auth is handled via PROTOAGENT_CUSTOM_HEADERS." })), 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 })] }));
151
179
  };
152
180
  export const ConfigResult = ({ configWritten }) => {
153
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,62 +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
- },
115
- ],
116
- },
117
- {
118
- id: 'cloudflare-gateway',
119
- name: 'Cloudflare AI Gateway',
120
- baseURL: undefined, // Overridden by PROTOAGENT_BASE_URL at runtime
121
- apiKeyEnvVar: 'PROTOAGENT_API_KEY',
122
- models: [
123
- {
124
- id: 'claude-opus-4-6',
125
- name: 'Claude Opus 4.6 (via CF Gateway)',
126
- contextWindow: 200_000,
127
- pricingPerMillionInput: 5.00,
128
- pricingPerMillionOutput: 25.00,
129
- },
130
- {
131
- id: 'claude-sonnet-4-6',
132
- name: 'Claude Sonnet 4.6 (via CF Gateway)',
133
- contextWindow: 200_000,
134
- pricingPerMillionInput: 3.00,
135
- pricingPerMillionOutput: 15.00,
136
- },
137
- {
138
- id: 'gpt-4o',
139
- name: 'GPT-4o (via CF Gateway)',
140
- contextWindow: 128_000,
141
- pricingPerMillionInput: 2.50,
142
- pricingPerMillionOutput: 10.00,
143
- },
144
- {
145
- id: 'gemini-2.5-flash',
146
- name: 'Gemini 2.5 Flash (via CF Gateway)',
147
- contextWindow: 1_000_000,
148
- pricingPerMillionInput: 0.075,
149
- pricingPerMillionOutput: 0.30,
150
- },
48
+ { id: 'llama-4-scout-17b-16e-instruct', name: 'Llama 4 Scout 17B', contextWindow: 128_000, pricingPerMillionInput: 0.0, pricingPerMillionOutput: 0.0 },
151
49
  ],
152
50
  },
153
51
  ];
154
- /** 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
+ }
155
99
  export function getProvider(providerId) {
156
- return SUPPORTED_MODELS.find((p) => p.id === providerId);
100
+ return getAllProviders().find((provider) => provider.id === providerId);
157
101
  }
158
- /** Find a model's details by provider and model ID. */
159
102
  export function getModelDetails(providerId, modelId) {
160
- const provider = getProvider(providerId);
161
- return provider?.models.find((m) => m.id === modelId);
103
+ return getProvider(providerId)?.models.find((model) => model.id === modelId);
162
104
  }
163
- /** Get model pricing in per-token format (for cost-tracker). */
164
105
  export function getModelPricing(providerId, modelId) {
165
106
  const details = getModelDetails(providerId, modelId);
166
107
  if (!details)
@@ -171,3 +112,11 @@ export function getModelPricing(providerId, modelId) {
171
112
  contextWindow: details.contextWindow,
172
113
  };
173
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.2",
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",