@miphamai/cli 0.36.1 → 0.37.0
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/package.json +1 -1
- package/src/agent/agent-registry.ts +1 -2
- package/src/agent/message-router.ts +37 -10
- package/src/agent/sub-agent.ts +7 -0
- package/src/agent/types.ts +6 -1
- package/src/core/context-token.ts +44 -0
- package/src/core/context.ts +16 -0
- package/src/core/credential-masker/output-scrub.ts +13 -0
- package/src/core/engine.ts +83 -4
- package/src/index.tsx +3 -0
- package/src/providers/anthropic.ts +25 -3
- package/src/providers/openai-compat.ts +30 -15
- package/src/providers/registry.ts +40 -0
- package/src/shared/types.ts +1 -0
- package/src/tools/agent/agent.ts +20 -4
- package/src/tools/agent/send-message.ts +2 -2
- package/src/tools/exec/bash.ts +14 -0
- package/src/tools/exec/git.ts +1 -1
- package/src/ui/app.tsx +11 -0
- package/src/ui/commands.ts +26 -6
package/package.json
CHANGED
|
@@ -73,7 +73,7 @@ export class AgentRegistry {
|
|
|
73
73
|
.split(',')
|
|
74
74
|
.map((s) => s.trim())
|
|
75
75
|
: undefined,
|
|
76
|
-
background:
|
|
76
|
+
background: typeof data.background === 'boolean' ? data.background : undefined,
|
|
77
77
|
memory: data.memory as 'user' | 'project' | 'local' | undefined,
|
|
78
78
|
source,
|
|
79
79
|
filePath: fullPath,
|
|
@@ -125,7 +125,6 @@ export class AgentRegistry {
|
|
|
125
125
|
systemPrompt: BUILTIN_SYSTEM_PROMPTS[builtinType],
|
|
126
126
|
model: 'inherit',
|
|
127
127
|
permissionMode: 'inherit',
|
|
128
|
-
background: false,
|
|
129
128
|
source: 'builtin',
|
|
130
129
|
}
|
|
131
130
|
}
|
|
@@ -2,6 +2,7 @@ import { getMessageBus } from './message-bus'
|
|
|
2
2
|
import { getFileInboxTransport } from './cross-session/file-inbox'
|
|
3
3
|
import { discoverSessions, createSessionInfo } from './cross-session/discovery'
|
|
4
4
|
import type { AgentMessage } from './message-bus'
|
|
5
|
+
import type { SessionInfo } from '../shared/types'
|
|
5
6
|
|
|
6
7
|
export interface RouteResult {
|
|
7
8
|
success: boolean
|
|
@@ -10,6 +11,35 @@ export interface RouteResult {
|
|
|
10
11
|
messageId?: string
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
export interface SessionResolution {
|
|
15
|
+
session?: SessionInfo
|
|
16
|
+
error?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a recipient among live sessions by bare name — session ID first,
|
|
21
|
+
* then session name. A name must uniquely match exactly one session; ambiguity
|
|
22
|
+
* and non-matches are reported as errors.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveRecipientSession(sessions: SessionInfo[], to: string): SessionResolution {
|
|
25
|
+
const byId = sessions.find((s) => s.id === to)
|
|
26
|
+
if (byId) return { session: byId }
|
|
27
|
+
|
|
28
|
+
const byName = sessions.filter((s) => s.name === to)
|
|
29
|
+
if (byName.length === 1) return { session: byName[0] }
|
|
30
|
+
if (byName.length > 1) {
|
|
31
|
+
return {
|
|
32
|
+
error:
|
|
33
|
+
`Ambiguous session name "${to}" matches ${byName.length} live sessions. ` +
|
|
34
|
+
`Use a session ID to disambiguate (ListAgents).`,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
error: `No active session found matching "${to}". Use ListAgents to discover available sessions.`,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
13
43
|
/**
|
|
14
44
|
* MessageRouter decides how to deliver a message based on the recipient.
|
|
15
45
|
*
|
|
@@ -33,17 +63,14 @@ export class MessageRouter {
|
|
|
33
63
|
}
|
|
34
64
|
}
|
|
35
65
|
|
|
36
|
-
// Cross-session routing
|
|
66
|
+
// Cross-session routing (by session ID or bare name)
|
|
37
67
|
const sessions = discoverSessions()
|
|
38
|
-
const
|
|
68
|
+
const resolution = resolveRecipientSession(sessions, to)
|
|
39
69
|
|
|
40
|
-
if (
|
|
41
|
-
return {
|
|
42
|
-
success: false,
|
|
43
|
-
routedTo: 'unknown',
|
|
44
|
-
error: `No active session found with ID "${to}". Use ListAgents to discover available sessions.`,
|
|
45
|
-
}
|
|
70
|
+
if (resolution.error) {
|
|
71
|
+
return { success: false, routedTo: 'unknown', error: resolution.error }
|
|
46
72
|
}
|
|
73
|
+
const targetSession = resolution.session!
|
|
47
74
|
|
|
48
75
|
// Get sender info
|
|
49
76
|
const senderSession = createSessionInfo(from, from)
|
|
@@ -53,7 +80,7 @@ export class MessageRouter {
|
|
|
53
80
|
const msg: AgentMessage = {
|
|
54
81
|
id: `xmsg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
55
82
|
from,
|
|
56
|
-
to,
|
|
83
|
+
to: targetSession.id,
|
|
57
84
|
summary: summary.slice(0, 200),
|
|
58
85
|
message,
|
|
59
86
|
timestamp: new Date(),
|
|
@@ -61,7 +88,7 @@ export class MessageRouter {
|
|
|
61
88
|
type: 'message',
|
|
62
89
|
}
|
|
63
90
|
|
|
64
|
-
const delivered = await transport.send(senderSession,
|
|
91
|
+
const delivered = await transport.send(senderSession, targetSession.id, msg)
|
|
65
92
|
if (!delivered) {
|
|
66
93
|
return { success: false, routedTo: 'inbox', error: 'Failed to write message to inbox.' }
|
|
67
94
|
}
|
package/src/agent/sub-agent.ts
CHANGED
|
@@ -270,6 +270,13 @@ export class SubAgent {
|
|
|
270
270
|
)
|
|
271
271
|
|
|
272
272
|
context.setSystemPrompt(systemPrompt)
|
|
273
|
+
|
|
274
|
+
// Seed inherited parent conversation (fork inheritance) as a byte-identical
|
|
275
|
+
// prefix so the provider prompt cache is reused.
|
|
276
|
+
if (options.inheritContext && options.inheritContext.messages.length > 0) {
|
|
277
|
+
context.seedMessages(options.inheritContext.messages)
|
|
278
|
+
}
|
|
279
|
+
|
|
273
280
|
context.addMessage({ role: 'user', content: prompt })
|
|
274
281
|
|
|
275
282
|
const messages = context.getMessages()
|
package/src/agent/types.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// apps/cli/src/agent/types.ts
|
|
2
2
|
|
|
3
|
+
import type { Message } from '../shared/index.ts'
|
|
4
|
+
|
|
3
5
|
export type SubAgentType = 'general' | 'explore' | 'plan' | 'code-review'
|
|
4
6
|
|
|
5
7
|
export interface AgentFrontmatter {
|
|
@@ -26,7 +28,8 @@ export interface AgentDefinition {
|
|
|
26
28
|
permissionMode: string
|
|
27
29
|
maxTurns?: number
|
|
28
30
|
skills?: string[]
|
|
29
|
-
background
|
|
31
|
+
/** Force background (true) or sync (false) execution. Unset inherits the tool default. */
|
|
32
|
+
background?: boolean
|
|
30
33
|
source: 'builtin' | 'project' | 'user'
|
|
31
34
|
filePath?: string
|
|
32
35
|
memory?: 'user' | 'project' | 'local' // agent memory scope
|
|
@@ -47,6 +50,8 @@ export interface SubAgentOptions {
|
|
|
47
50
|
onProgress?: (chunk: string) => void
|
|
48
51
|
/** When set, tool executions use this path as cwd (git worktree isolation). */
|
|
49
52
|
worktreePath?: string
|
|
53
|
+
/** Seed the sub-agent with a parent conversation prefix (e.g., fork inheritance). */
|
|
54
|
+
inheritContext?: { messages: Message[] }
|
|
50
55
|
/** CRSI: when false, skip pattern analysis after agent execution. Default true. */
|
|
51
56
|
autoPatternAnalysis?: boolean
|
|
52
57
|
}
|
|
@@ -12,6 +12,8 @@ export interface CacheTracker {
|
|
|
12
12
|
isInCache(msg: Message): boolean
|
|
13
13
|
/** Returns a snapshot of the cache state for metrics / logging. */
|
|
14
14
|
getStatus(): CacheStatus
|
|
15
|
+
/** Record the set of messages currently held in the provider prompt cache. */
|
|
16
|
+
markCached?(messages: Message[]): void
|
|
15
17
|
/** Clear the tracker state (does NOT evict from provider cache). */
|
|
16
18
|
invalidate(): void
|
|
17
19
|
}
|
|
@@ -41,11 +43,53 @@ export class NoopCacheTracker implements CacheTracker {
|
|
|
41
43
|
this.messageCount = messages.length
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
markCached(_messages: Message[]): void {
|
|
47
|
+
// No-op — prompt caching is disabled with this tracker.
|
|
48
|
+
}
|
|
49
|
+
|
|
44
50
|
invalidate(): void {
|
|
45
51
|
this.messageCount = 0
|
|
46
52
|
}
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Tracks which messages are currently held in the provider's prompt cache.
|
|
57
|
+
* The engine informs it of the cached prefix after each successful request
|
|
58
|
+
* (Anthropic caches "all messages except the newest"; DeepSeek/OpenAI auto-cache
|
|
59
|
+
* the same prefix). Object-identity based, so it stays valid across the
|
|
60
|
+
* ContextManager's shallow-copied message arrays.
|
|
61
|
+
*/
|
|
62
|
+
export class PrefixCacheTracker implements CacheTracker {
|
|
63
|
+
private cachedMessages = new WeakSet<Message>()
|
|
64
|
+
private cachedCount = 0
|
|
65
|
+
private cachedTokens = 0
|
|
66
|
+
|
|
67
|
+
markCached(messages: Message[]): void {
|
|
68
|
+
this.cachedMessages = new WeakSet(messages)
|
|
69
|
+
this.cachedCount = messages.length
|
|
70
|
+
this.cachedTokens = messages.reduce((sum, m) => sum + estimateMessageTokens(m), 0)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
isInCache(msg: Message): boolean {
|
|
74
|
+
return this.cachedMessages.has(msg)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getStatus(): CacheStatus {
|
|
78
|
+
return {
|
|
79
|
+
totalMessages: this.cachedCount,
|
|
80
|
+
cachedMessages: this.cachedCount,
|
|
81
|
+
cachedTokens: this.cachedTokens,
|
|
82
|
+
uncachedTokens: 0,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
invalidate(): void {
|
|
87
|
+
this.cachedMessages = new WeakSet()
|
|
88
|
+
this.cachedCount = 0
|
|
89
|
+
this.cachedTokens = 0
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
49
93
|
/**
|
|
50
94
|
* Estimate the token count for a single message.
|
|
51
95
|
*
|
package/src/core/context.ts
CHANGED
|
@@ -116,6 +116,17 @@ export class ContextManager {
|
|
|
116
116
|
this.checkCompression()
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Seed a batch of pre-existing messages (e.g., an inherited parent
|
|
121
|
+
* conversation). Unlike addMessage, this does not trigger compaction so the
|
|
122
|
+
* byte-identical prefix is preserved for prompt-cache hits.
|
|
123
|
+
*/
|
|
124
|
+
seedMessages(messages: Message[]): void {
|
|
125
|
+
if (messages.length === 0) return
|
|
126
|
+
this.messages.push(...messages)
|
|
127
|
+
this.reEstimateTokens()
|
|
128
|
+
}
|
|
129
|
+
|
|
119
130
|
getMessages(): Message[] {
|
|
120
131
|
return [...this.messages]
|
|
121
132
|
}
|
|
@@ -252,6 +263,11 @@ export class ContextManager {
|
|
|
252
263
|
this.cacheTracker = tracker
|
|
253
264
|
}
|
|
254
265
|
|
|
266
|
+
/** Mark a set of messages as cached by the provider (called after a request). */
|
|
267
|
+
markCached(messages: Message[]): void {
|
|
268
|
+
this.cacheTracker.markCached?.(messages)
|
|
269
|
+
}
|
|
270
|
+
|
|
255
271
|
/** Get a snapshot of the provider prompt-cache state. */
|
|
256
272
|
getCacheStatus(): CacheStatus {
|
|
257
273
|
return this.cacheTracker.getStatus()
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { CREDENTIAL_SENTINEL } from './types'
|
|
2
2
|
import type { CredentialMaskingConfig } from '../../shared/index.ts'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Well-known secret token prefixes. These are high-signal formats that must
|
|
6
|
+
* never leak into output, independent of the configured patterns.
|
|
7
|
+
* - GitHub: ghp_ (personal access), gho_ (OAuth), ghs_ (server), ghu_/ghr_ (user/server-to-server), github_pat_ (fine-grained)
|
|
8
|
+
* - GitLab: glpat- (personal access), gldt- (deploy), glrt- (runner), gloas- (OAuth app)
|
|
9
|
+
*/
|
|
10
|
+
const TOKEN_REDACTION_PATTERN =
|
|
11
|
+
/\b(?:ghp_|gho_|ghs_|ghu_|ghr_|github_pat_|glpat-|gldt-|glrt-|gloas-)[A-Za-z0-9_-]{8,}/g
|
|
12
|
+
|
|
4
13
|
/**
|
|
5
14
|
* Scrub credential patterns from stdout/stderr output.
|
|
6
15
|
* Uses the configured output_scrubbing patterns to detect and replace
|
|
@@ -10,6 +19,10 @@ export function maskOutput(output: string, config: CredentialMaskingConfig): str
|
|
|
10
19
|
if (!config.enabled || !config.output_scrubbing.enabled) return output
|
|
11
20
|
|
|
12
21
|
let masked = output
|
|
22
|
+
|
|
23
|
+
// Redact bare secret tokens by prefix (always on, independent of config patterns).
|
|
24
|
+
masked = masked.replace(TOKEN_REDACTION_PATTERN, CREDENTIAL_SENTINEL)
|
|
25
|
+
|
|
13
26
|
for (const pattern of config.output_scrubbing.patterns) {
|
|
14
27
|
try {
|
|
15
28
|
// Strip (?i) inline flags — JS uses the 'i' flag instead
|
package/src/core/engine.ts
CHANGED
|
@@ -446,13 +446,12 @@ export class QueryEngine {
|
|
|
446
446
|
|
|
447
447
|
// Stream model response
|
|
448
448
|
try {
|
|
449
|
-
for await (const chunk of this.
|
|
450
|
-
model: this.registry.getActiveModel(),
|
|
449
|
+
for await (const chunk of this.chatWithFallback(
|
|
451
450
|
messages,
|
|
452
451
|
systemPrompt,
|
|
453
|
-
|
|
452
|
+
toolDefs.length > 0 ? toolDefs : undefined,
|
|
454
453
|
signal,
|
|
455
|
-
|
|
454
|
+
)) {
|
|
456
455
|
yield chunk
|
|
457
456
|
|
|
458
457
|
if (chunk.type === 'error') {
|
|
@@ -533,6 +532,12 @@ export class QueryEngine {
|
|
|
533
532
|
return
|
|
534
533
|
}
|
|
535
534
|
|
|
535
|
+
// Record the provider's cached prefix (all messages except the newest)
|
|
536
|
+
// for cache-aware microcompaction.
|
|
537
|
+
if (messages.length >= 2) {
|
|
538
|
+
this.context.markCached(messages.slice(0, -1))
|
|
539
|
+
}
|
|
540
|
+
|
|
536
541
|
// Track last assistant content for goal checking
|
|
537
542
|
if (assistantContent) {
|
|
538
543
|
this.lastAssistantContent = assistantContent
|
|
@@ -1144,6 +1149,80 @@ export class QueryEngine {
|
|
|
1144
1149
|
return this.registry
|
|
1145
1150
|
}
|
|
1146
1151
|
|
|
1152
|
+
/**
|
|
1153
|
+
* Stream a chat response with graceful provider fallback (v2.1.229 alignment).
|
|
1154
|
+
*
|
|
1155
|
+
* On a connection/availability failure from the active provider (thrown
|
|
1156
|
+
* network error or an error chunk), switches to the configured default
|
|
1157
|
+
* provider and retries once, yielding a `warning` chunk so the UI can tell
|
|
1158
|
+
* the user the provider degraded. Abort errors propagate untouched.
|
|
1159
|
+
*/
|
|
1160
|
+
private async *chatWithFallback(
|
|
1161
|
+
messages: import('../shared/types').Message[],
|
|
1162
|
+
systemPrompt: string,
|
|
1163
|
+
toolDefs: Record<string, unknown>[] | undefined,
|
|
1164
|
+
signal?: AbortSignal,
|
|
1165
|
+
): AsyncGenerator<StreamChunk> {
|
|
1166
|
+
const activeId = this.registry.getActive().config.id
|
|
1167
|
+
const defaultId = this.registry.getDefaultProviderId()
|
|
1168
|
+
|
|
1169
|
+
// ── Attempt 1: active provider ──
|
|
1170
|
+
let failure: string | null = null
|
|
1171
|
+
try {
|
|
1172
|
+
for await (const chunk of this.registry.chat({
|
|
1173
|
+
model: this.registry.getActiveModel(),
|
|
1174
|
+
messages,
|
|
1175
|
+
systemPrompt,
|
|
1176
|
+
tools: toolDefs,
|
|
1177
|
+
signal,
|
|
1178
|
+
})) {
|
|
1179
|
+
if (chunk.type === 'error') {
|
|
1180
|
+
failure = chunk.error ?? 'Unknown error'
|
|
1181
|
+
break
|
|
1182
|
+
}
|
|
1183
|
+
yield chunk
|
|
1184
|
+
}
|
|
1185
|
+
if (failure === null) return
|
|
1186
|
+
} catch (err) {
|
|
1187
|
+
if (isAbortError(err)) throw err
|
|
1188
|
+
failure = String(err)
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// ── Fallback: configured default provider, once ──
|
|
1192
|
+
if (!defaultId || defaultId === activeId || !this.registry.get(defaultId)) {
|
|
1193
|
+
yield { type: 'error', error: failure }
|
|
1194
|
+
return
|
|
1195
|
+
}
|
|
1196
|
+
const fallbackModel = this.registry
|
|
1197
|
+
.get(defaultId)!
|
|
1198
|
+
.config.models.find((m) => m.status === 'active')?.id
|
|
1199
|
+
if (!fallbackModel) {
|
|
1200
|
+
yield { type: 'error', error: failure }
|
|
1201
|
+
return
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
this.registry.switchProvider(defaultId, fallbackModel)
|
|
1205
|
+
yield {
|
|
1206
|
+
type: 'warning',
|
|
1207
|
+
content: `${activeId} unreachable — degraded to ${defaultId} (${fallbackModel})`,
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
try {
|
|
1211
|
+
for await (const chunk of this.registry.chat({
|
|
1212
|
+
model: fallbackModel,
|
|
1213
|
+
messages,
|
|
1214
|
+
systemPrompt,
|
|
1215
|
+
tools: toolDefs,
|
|
1216
|
+
signal,
|
|
1217
|
+
})) {
|
|
1218
|
+
yield chunk
|
|
1219
|
+
}
|
|
1220
|
+
} catch (err) {
|
|
1221
|
+
if (isAbortError(err)) throw err
|
|
1222
|
+
yield { type: 'error', error: String(err) }
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1147
1226
|
getTools(): Map<string, ToolDefinition> {
|
|
1148
1227
|
return this.tools
|
|
1149
1228
|
}
|
package/src/index.tsx
CHANGED
|
@@ -21,6 +21,7 @@ import { bootstrapProviders } from './providers/bootstrap'
|
|
|
21
21
|
import { InstructionsLoader } from './core/instructions'
|
|
22
22
|
import { loadSessionMemories, getMemoryManager } from './core/memory/memory-loader'
|
|
23
23
|
import { ContextManager } from './core/context'
|
|
24
|
+
import { PrefixCacheTracker } from './core/context-token'
|
|
24
25
|
import { QueryEngine } from './core/engine'
|
|
25
26
|
import { ExperienceRuleEngine } from './core/rule-engine.js'
|
|
26
27
|
import { SessionStore } from './core/session-store'
|
|
@@ -325,6 +326,8 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
325
326
|
compactionThreshold: 0.9,
|
|
326
327
|
contextWindow: adaptiveThresholds ? modelContextWindow : undefined,
|
|
327
328
|
})
|
|
329
|
+
// Cache-aware microcompaction: track the provider's prompt-cache prefix.
|
|
330
|
+
context.setCacheTracker(new PrefixCacheTracker())
|
|
328
331
|
|
|
329
332
|
// Adaptive memory budget: scale with model's context window
|
|
330
333
|
getMemoryManager().setContextWindow(modelContextWindow)
|
|
@@ -52,15 +52,20 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
52
52
|
let currentToolId = ''
|
|
53
53
|
let accumulatedToolInput = ''
|
|
54
54
|
|
|
55
|
+
const messages = this.convertMessages(req.messages)
|
|
56
|
+
this.markPrefixCacheBreakpoint(messages)
|
|
57
|
+
|
|
55
58
|
const body: Record<string, unknown> = {
|
|
56
59
|
model: req.model,
|
|
57
60
|
max_tokens: req.maxTokens || 4096,
|
|
58
61
|
stream: true,
|
|
59
|
-
messages
|
|
62
|
+
messages,
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
if (req.systemPrompt) {
|
|
63
|
-
|
|
66
|
+
// Mark the system prompt for prompt caching — it's the largest stable
|
|
67
|
+
// block and byte-identical across turns, so it always hits the cache.
|
|
68
|
+
body.system = [{ type: 'text', text: req.systemPrompt, cache_control: { type: 'ephemeral' } }]
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
if (req.temperature !== undefined) {
|
|
@@ -68,11 +73,14 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
if (req.tools && req.tools.length > 0) {
|
|
71
|
-
|
|
76
|
+
const tools: Record<string, unknown>[] = req.tools.map((t) => ({
|
|
72
77
|
name: t.name,
|
|
73
78
|
description: t.description,
|
|
74
79
|
input_schema: t.parameters || t.input_schema || { type: 'object', properties: {} },
|
|
75
80
|
}))
|
|
81
|
+
// Cache the tools: mark the last tool definition as a breakpoint.
|
|
82
|
+
tools[tools.length - 1]!.cache_control = { type: 'ephemeral' }
|
|
83
|
+
body.tools = tools
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
const response = await fetchWithRetry(`${this.baseUrl}/messages`, {
|
|
@@ -239,6 +247,20 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
239
247
|
return apiKey.length > 0 && apiKey.startsWith('sk-ant-')
|
|
240
248
|
}
|
|
241
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Mark the stable conversation prefix for prompt caching. The breakpoint is
|
|
252
|
+
* placed on the last block of the second-to-last message, leaving only the
|
|
253
|
+
* newest message uncached.
|
|
254
|
+
*/
|
|
255
|
+
private markPrefixCacheBreakpoint(messages: Record<string, unknown>[]): void {
|
|
256
|
+
if (messages.length < 2) return
|
|
257
|
+
const boundary = messages[messages.length - 2]!
|
|
258
|
+
const content = boundary.content
|
|
259
|
+
if (!Array.isArray(content) || content.length === 0) return
|
|
260
|
+
const lastBlock = content[content.length - 1] as Record<string, unknown>
|
|
261
|
+
lastBlock.cache_control = { type: 'ephemeral' }
|
|
262
|
+
}
|
|
263
|
+
|
|
242
264
|
private convertMessages(messages: Message[]): Record<string, unknown>[] {
|
|
243
265
|
const result: Record<string, unknown>[] = []
|
|
244
266
|
|
|
@@ -8,9 +8,13 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
8
8
|
constructor(public config: ProviderConfig) {}
|
|
9
9
|
|
|
10
10
|
async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
|
|
11
|
-
// Accept both baseUrl and baseURL (common YAML typo)
|
|
11
|
+
// Accept both baseUrl and baseURL (common YAML typo); resolve env templates
|
|
12
12
|
const rawBase = (this.config as any).baseUrl || (this.config as any).baseURL
|
|
13
|
-
const baseUrl =
|
|
13
|
+
const baseUrl =
|
|
14
|
+
this.resolveEnvTemplate(
|
|
15
|
+
rawBase?.replace(/\/+$/, '') || '',
|
|
16
|
+
'OpenAI-compatible provider: baseUrl',
|
|
17
|
+
) || 'https://api.openai.com/v1'
|
|
14
18
|
const apiKey = this.resolveApiKey(this.config.apiKey)
|
|
15
19
|
|
|
16
20
|
const body = {
|
|
@@ -224,11 +228,18 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
224
228
|
async healthCheck(): Promise<boolean> {
|
|
225
229
|
try {
|
|
226
230
|
const rawBase = (this.config as any).baseUrl || (this.config as any).baseURL
|
|
227
|
-
const baseUrl =
|
|
231
|
+
const baseUrl =
|
|
232
|
+
this.resolveEnvTemplate(
|
|
233
|
+
rawBase?.replace(/\/+$/, '') || '',
|
|
234
|
+
'OpenAI-compatible provider: baseUrl',
|
|
235
|
+
) || 'https://api.openai.com/v1'
|
|
228
236
|
const apiKey = this.resolveApiKey(this.config.apiKey)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
237
|
+
// 5s timeout so a down endpoint doesn't hang health checks (v2.1.229 alignment)
|
|
238
|
+
const res = await fetchWithRetry(
|
|
239
|
+
`${baseUrl}/models`,
|
|
240
|
+
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
|
241
|
+
{ timeout: 5000, maxRetries: 0 },
|
|
242
|
+
)
|
|
232
243
|
return res.ok
|
|
233
244
|
} catch {
|
|
234
245
|
return false
|
|
@@ -366,21 +377,25 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
366
377
|
}
|
|
367
378
|
}
|
|
368
379
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
let match =
|
|
372
|
-
if (!match) match =
|
|
380
|
+
/** Resolve a `${VAR}` / `$VAR` template against the environment. */
|
|
381
|
+
private resolveEnvTemplate(value: string, warnPrefix: string): string {
|
|
382
|
+
let match = value.match(/^\$\{(.+)\}$/)
|
|
383
|
+
if (!match) match = value.match(/^\$([A-Z_][A-Z0-9_]*)$/)
|
|
373
384
|
if (match?.[1]) {
|
|
374
385
|
const varName = match[1]
|
|
375
|
-
const
|
|
376
|
-
if (!
|
|
386
|
+
const envValue = process.env[varName]
|
|
387
|
+
if (!envValue) {
|
|
377
388
|
process.stderr.write(
|
|
378
|
-
`⚠
|
|
389
|
+
`⚠ ${warnPrefix} references $${varName} but that environment variable is not set\n`,
|
|
379
390
|
)
|
|
380
391
|
return ''
|
|
381
392
|
}
|
|
382
|
-
return
|
|
393
|
+
return envValue
|
|
383
394
|
}
|
|
384
|
-
return
|
|
395
|
+
return value
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private resolveApiKey(keyTemplate: string): string {
|
|
399
|
+
return this.resolveEnvTemplate(keyTemplate, 'OpenAI-compatible provider: apiKey')
|
|
385
400
|
}
|
|
386
401
|
}
|
|
@@ -21,10 +21,24 @@ export class ProviderRegistry {
|
|
|
21
21
|
private providers = new Map<string, ProviderInstance>()
|
|
22
22
|
private activeProviderId: string
|
|
23
23
|
private activeModelId: string
|
|
24
|
+
private defaultProviderId: string
|
|
25
|
+
private defaultModelId: string
|
|
24
26
|
|
|
25
27
|
constructor(providers: ProviderConfig[], defaultProvider: string, defaultModel: string) {
|
|
26
28
|
this.activeProviderId = defaultProvider
|
|
27
29
|
this.activeModelId = defaultModel
|
|
30
|
+
this.defaultProviderId = defaultProvider
|
|
31
|
+
this.defaultModelId = defaultModel
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The configured default provider id (used for fallback routing). */
|
|
35
|
+
getDefaultProviderId(): string {
|
|
36
|
+
return this.defaultProviderId
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The configured default model id. */
|
|
40
|
+
getDefaultModelId(): string {
|
|
41
|
+
return this.defaultModelId
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
register(id: string, instance: ProviderInstance): void {
|
|
@@ -72,6 +86,32 @@ export class ProviderRegistry {
|
|
|
72
86
|
return undefined
|
|
73
87
|
}
|
|
74
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Check a single provider's health. Returns undefined if not registered,
|
|
91
|
+
* otherwise the provider's healthCheck() result. Never throws.
|
|
92
|
+
*/
|
|
93
|
+
async healthStatus(id: string): Promise<boolean | undefined> {
|
|
94
|
+
const provider = this.providers.get(id)
|
|
95
|
+
if (!provider) return undefined
|
|
96
|
+
try {
|
|
97
|
+
return await provider.healthCheck()
|
|
98
|
+
} catch {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Health of all registered providers, checked concurrently.
|
|
105
|
+
* Returns a Map of provider id → reachable boolean.
|
|
106
|
+
*/
|
|
107
|
+
async healthMap(): Promise<Map<string, boolean>> {
|
|
108
|
+
const ids = this.listIds()
|
|
109
|
+
const results = await Promise.all(
|
|
110
|
+
ids.map(async (id) => [id, (await this.healthStatus(id)) ?? false] as const),
|
|
111
|
+
)
|
|
112
|
+
return new Map(results)
|
|
113
|
+
}
|
|
114
|
+
|
|
75
115
|
async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
|
|
76
116
|
const provider = this.getActive()
|
|
77
117
|
yield* provider.chat({ ...req, model: req.model || this.activeModelId })
|
package/src/shared/types.ts
CHANGED
package/src/tools/agent/agent.ts
CHANGED
|
@@ -5,13 +5,26 @@ import { getBackgroundAgentRegistry } from '../../agent/background-registry'
|
|
|
5
5
|
|
|
6
6
|
const VALID_TYPES: SubAgentType[] = ['general', 'explore', 'plan', 'code-review']
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Resolve whether a sub-agent should run in the background.
|
|
10
|
+
* Precedence: explicit `run_in_background` param > agent frontmatter
|
|
11
|
+
* `background` field > default (background, Claude Code 2.1.232 parity).
|
|
12
|
+
*/
|
|
13
|
+
export function resolveRunInBackground(
|
|
14
|
+
runInBackground: boolean | undefined,
|
|
15
|
+
agentDef?: { background?: boolean },
|
|
16
|
+
): boolean {
|
|
17
|
+
return runInBackground ?? agentDef?.background ?? true
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
export const agentTool: ToolDefinition = {
|
|
9
21
|
name: 'Agent',
|
|
10
22
|
description:
|
|
11
23
|
'Launch a sub-agent to handle complex, multi-step tasks independently. ' +
|
|
12
24
|
'Available types: general (default), explore (code search), plan (design), code-review. ' +
|
|
13
|
-
'
|
|
14
|
-
'
|
|
25
|
+
'Runs in the background by default — returns a task ID immediately; results are ' +
|
|
26
|
+
'retrievable via the Task tool (output action) or Agent View. ' +
|
|
27
|
+
'Set run_in_background: false to run synchronously.',
|
|
15
28
|
category: 'agent',
|
|
16
29
|
permission: 'ask',
|
|
17
30
|
parameters: {
|
|
@@ -26,7 +39,7 @@ export const agentTool: ToolDefinition = {
|
|
|
26
39
|
run_in_background: {
|
|
27
40
|
type: 'boolean',
|
|
28
41
|
description:
|
|
29
|
-
'When
|
|
42
|
+
'When false, execute synchronously and return the result directly. Default: true (background).',
|
|
30
43
|
},
|
|
31
44
|
},
|
|
32
45
|
required: ['description', 'prompt'],
|
|
@@ -35,7 +48,6 @@ export const agentTool: ToolDefinition = {
|
|
|
35
48
|
const description = params.description as string
|
|
36
49
|
const prompt = params.prompt as string
|
|
37
50
|
const agentType = (params.subagent_type as SubAgentType) || 'general'
|
|
38
|
-
const runInBackground = params.run_in_background === true
|
|
39
51
|
|
|
40
52
|
if (!VALID_TYPES.includes(agentType)) {
|
|
41
53
|
return {
|
|
@@ -58,6 +70,10 @@ export const agentTool: ToolDefinition = {
|
|
|
58
70
|
|
|
59
71
|
// Resolve agent definition from registry (custom > builtin)
|
|
60
72
|
const agentDef = ctx.agentRegistry?.resolve(agentType)
|
|
73
|
+
const runInBackground = resolveRunInBackground(
|
|
74
|
+
params.run_in_background as boolean | undefined,
|
|
75
|
+
agentDef,
|
|
76
|
+
)
|
|
61
77
|
|
|
62
78
|
try {
|
|
63
79
|
const sub = new SubAgent(
|
|
@@ -6,7 +6,7 @@ export const sendMessageTool: ToolDefinition = {
|
|
|
6
6
|
description:
|
|
7
7
|
'Send a message to another agent or session. ' +
|
|
8
8
|
'Use "main" for the parent conversation, a background task ID for same-process agents, ' +
|
|
9
|
-
'or a session ID for cross-session messaging (use ListAgents to discover sessions).',
|
|
9
|
+
'or a session ID (or unique session name) for cross-session messaging (use ListAgents to discover sessions).',
|
|
10
10
|
category: 'agent',
|
|
11
11
|
permission: 'auto',
|
|
12
12
|
parameters: {
|
|
@@ -15,7 +15,7 @@ export const sendMessageTool: ToolDefinition = {
|
|
|
15
15
|
to: {
|
|
16
16
|
type: 'string',
|
|
17
17
|
description:
|
|
18
|
-
'Recipient: "main" for the parent conversation, a background task ID, or a session ID for cross-session messaging.',
|
|
18
|
+
'Recipient: "main" for the parent conversation, a background task ID, or a session ID / unique session name for cross-session messaging.',
|
|
19
19
|
},
|
|
20
20
|
summary: {
|
|
21
21
|
type: 'string',
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
|
|
2
2
|
import { sanitizeCommand } from '../../shared/sanitize.ts'
|
|
3
|
+
import { DANGEROUS_GIT_PATTERNS } from './git.ts'
|
|
3
4
|
|
|
4
5
|
// Injected at startup — set via index.tsx
|
|
5
6
|
let credentialConfig: CredentialMaskingConfig | undefined
|
|
@@ -124,6 +125,19 @@ function isBlocked(command: string): string | null {
|
|
|
124
125
|
return `Command "${sanitizedFirstWord}" rejected by security policy.`
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
// Detect dangerous git operations invoked via Bash. The Git tool guards these,
|
|
129
|
+
// but shelling out with `git push --force` would bypass it — apply the same
|
|
130
|
+
// pattern list to any Bash command that starts with a git invocation.
|
|
131
|
+
const gitInvocation = normalized.trim().match(/^(?:sudo\s+)?git(?:\s+|$)(.*)$/i)
|
|
132
|
+
if (gitInvocation) {
|
|
133
|
+
const subcommand = gitInvocation[1] || ''
|
|
134
|
+
for (const { pattern, description } of DANGEROUS_GIT_PATTERNS) {
|
|
135
|
+
if (pattern.test(subcommand)) {
|
|
136
|
+
return `Dangerous git command blocked: "${description}". Run manually if intended.`
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
127
141
|
// Check dangerous patterns (on original, normalized, and sanitized)
|
|
128
142
|
for (const pattern of BLOCKED_PATTERNS) {
|
|
129
143
|
if (pattern.test(command) || pattern.test(normalized) || pattern.test(sanitized)) {
|
package/src/tools/exec/git.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ToolDefinition } from '../../shared/index.ts'
|
|
|
2
2
|
|
|
3
3
|
// P0-4 (v2.1.222 alignment): Regex-based word-boundary patterns replace
|
|
4
4
|
// fragile substring matching. Each pattern describes what it blocks.
|
|
5
|
-
const DANGEROUS_GIT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
|
|
5
|
+
export const DANGEROUS_GIT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
|
|
6
6
|
// Destructive push
|
|
7
7
|
{ pattern: /\bpush\s+.*--force(?:-with-lease)?\b/, description: 'push --force' },
|
|
8
8
|
{ pattern: /\bpush\s+.*-[fF]\b/, description: 'push -f (force)' },
|
package/src/ui/app.tsx
CHANGED
|
@@ -658,6 +658,17 @@ export function App({
|
|
|
658
658
|
])
|
|
659
659
|
}
|
|
660
660
|
|
|
661
|
+
if (chunk.type === 'warning' && chunk.content) {
|
|
662
|
+
setMessages((prev) => [...prev, { role: 'system', content: `⚠ ${chunk.content}` }])
|
|
663
|
+
// Keep the footer's provider/model in sync after an automatic fallback
|
|
664
|
+
// switch. RemoteEngine's registry is a stub without getActive.
|
|
665
|
+
const registry = engine.getRegistry()
|
|
666
|
+
if ('getActive' in registry) {
|
|
667
|
+
setProviderId(registry.getActive().config.id)
|
|
668
|
+
setModelId(registry.getActiveModel())
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
661
672
|
if (chunk.type === 'task_notification' && chunk.taskNotification) {
|
|
662
673
|
const tn = chunk.taskNotification
|
|
663
674
|
const isDone = tn.status === 'completed'
|
package/src/ui/commands.ts
CHANGED
|
@@ -317,12 +317,20 @@ const contextCmd: CommandHandler = (ctx) => {
|
|
|
317
317
|
}
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
-
const statusCmd: CommandHandler = (ctx) => {
|
|
320
|
+
const statusCmd: CommandHandler = async (ctx) => {
|
|
321
321
|
const t = resolveT(ctx)
|
|
322
322
|
const c = ctx.engine.getContext()
|
|
323
323
|
const tools = ctx.engine.getTools()
|
|
324
324
|
const runtime = typeof Bun !== 'undefined' ? 'Bun' : 'Node.js'
|
|
325
325
|
const runtimeVer = typeof Bun !== 'undefined' ? Bun.version : process.version
|
|
326
|
+
const health = ctx.engine.getRegistry()
|
|
327
|
+
? await ctx.engine.getRegistry().healthMap()
|
|
328
|
+
: new Map<string, boolean>()
|
|
329
|
+
const providerHealth = ctx.config.providers
|
|
330
|
+
.filter((p) => p.status !== 'upcoming')
|
|
331
|
+
.map((p) => `${p.id} ${health.get(p.id) ? '🟢' : '🔴'}`)
|
|
332
|
+
.join(' ')
|
|
333
|
+
|
|
326
334
|
return {
|
|
327
335
|
content: stripIndent`
|
|
328
336
|
${t('commands.status.session_title')}
|
|
@@ -337,6 +345,9 @@ const statusCmd: CommandHandler = (ctx) => {
|
|
|
337
345
|
${t('commands.status.platform')} ${process.platform} ${process.arch}
|
|
338
346
|
${t('commands.status.runtime')} ${runtime} ${runtimeVer}
|
|
339
347
|
${t('commands.status.cwd')} ${process.cwd()}
|
|
348
|
+
|
|
349
|
+
Providers
|
|
350
|
+
${providerHealth}
|
|
340
351
|
`,
|
|
341
352
|
}
|
|
342
353
|
}
|
|
@@ -449,12 +460,19 @@ const providerCmd: CommandHandler = (ctx) => {
|
|
|
449
460
|
}
|
|
450
461
|
}
|
|
451
462
|
|
|
452
|
-
const providersCmd: CommandHandler = (ctx) => {
|
|
463
|
+
const providersCmd: CommandHandler = async (ctx) => {
|
|
453
464
|
const t = resolveT(ctx)
|
|
454
|
-
const
|
|
455
|
-
(
|
|
456
|
-
|
|
457
|
-
|
|
465
|
+
const health = ctx.engine.getRegistry()
|
|
466
|
+
? await ctx.engine.getRegistry().healthMap()
|
|
467
|
+
: new Map<string, boolean>()
|
|
468
|
+
|
|
469
|
+
const lines = ctx.config.providers.map((p) => {
|
|
470
|
+
if (p.status === 'upcoming') {
|
|
471
|
+
return ` ${p.id.padEnd(14)} ${p.name.padEnd(20)} ${p.protocol.padEnd(18)} ${p.models.length} models ⏳ [upcoming]`
|
|
472
|
+
}
|
|
473
|
+
const ok = health.get(p.id) ?? false
|
|
474
|
+
return ` ${p.id.padEnd(14)} ${p.name.padEnd(20)} ${p.protocol.padEnd(18)} ${p.models.length} models ${ok ? '🟢 reachable' : '🔴 unreachable'}`
|
|
475
|
+
})
|
|
458
476
|
return {
|
|
459
477
|
content: `${t('commands.providers.title')}\n\n${lines.join('\n')}\n\n${t('commands.providers.current', { provider: ctx.providerId, model: ctx.modelId })}`,
|
|
460
478
|
}
|
|
@@ -4023,8 +4041,10 @@ const forkCmd: CommandHandler = async (ctx, args) => {
|
|
|
4023
4041
|
ctx.engine.getTools(),
|
|
4024
4042
|
ctx.engine.getPermission(),
|
|
4025
4043
|
)
|
|
4044
|
+
const parentContext = ctx.engine.getContext()
|
|
4026
4045
|
const result = await sa.execute(prompt, 'fork: ' + prompt.slice(0, 60), {
|
|
4027
4046
|
worktreePath: wtPath,
|
|
4047
|
+
inheritContext: { messages: parentContext.getMessages() },
|
|
4028
4048
|
})
|
|
4029
4049
|
try {
|
|
4030
4050
|
const { execSync: ex } = await import('node:child_process')
|