Skip to content

Commit c925f5c

Browse files
edyedy
authored andcommitted
feat(core): implement subagent isolation, wire protocol, and V2 session pipeline
- Task 12: Subagent process isolation with concurrent limiter (20 global / 50 per-session), resume mechanism, fork mode, and SessionRunCoordinator integration - Task 13: Wire protocol extension — EventV2 per-agent event streams, session fork operation, turn lifecycle events, schema version tracking, Subagent.Spawned/Completed events - Add context-levels pipeline (5-tier degradation), cost tracking, token estimation, dynamic model switching, hook system, progressive tool disclosure, Agent-as-Markdown, graceful truncation, background dream mechanism, steering messages
1 parent ac09711 commit c925f5c

32 files changed

Lines changed: 2513 additions & 115 deletions

packages/core/src/agent.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { makeLocationNode } from "./effect/app-node"
44
import { Array, Context, Effect, Layer, Types } from "effect"
55
import { Agent } from "@opencode-ai/schema/agent"
66
import { State } from "./state"
7+
import type { DeepMutable } from "./schema"
78

89
export const ID = Agent.ID
910
export type ID = typeof ID.Type
@@ -20,15 +21,15 @@ export interface Selection {
2021
}
2122

2223
type Data = {
23-
agents: Map<ID, Types.DeepMutable<Info>>
24+
agents: Map<ID, DeepMutable<Info>>
2425
default?: ID
2526
}
2627

2728
export type Draft = {
2829
list: () => readonly Info[]
2930
get: (id: ID) => Info | undefined
3031
default: (id: ID | undefined) => void
31-
update: (id: ID, fn: (agent: Types.DeepMutable<Info>) => void) => void
32+
update: (id: ID, fn: (agent: DeepMutable<Info>) => void) => void
3233
remove: (id: ID) => void
3334
}
3435

@@ -54,7 +55,7 @@ const layer = Layer.effect(
5455
draft.default = id
5556
},
5657
update: (id, fn) => {
57-
const current = draft.agents.get(id) ?? (Info.empty(id) as Types.DeepMutable<Info>)
58+
const current = draft.agents.get(id) ?? (Info.empty(id) as DeepMutable<Info>)
5859
if (!draft.agents.has(id)) draft.agents.set(id, current)
5960
fn(current)
6061
current.id = id

packages/core/src/config/agent.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export const Color = Schema.Union([
1010
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
1111
])
1212

13+
export const ModelPreference = Schema.Struct({
14+
continuation: Schema.String.pipe(Schema.optional),
15+
})
16+
1317
export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
1418
model: Schema.String.pipe(Schema.optional),
1519
variant: Schema.String.pipe(Schema.optional),
@@ -22,4 +26,7 @@ export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
2226
steps: PositiveInt.pipe(Schema.optional),
2327
disabled: Schema.Boolean.pipe(Schema.optional),
2428
permissions: Permission.Ruleset.pipe(Schema.optional),
29+
model_preference: ModelPreference.pipe(Schema.optional),
30+
tools: Schema.Array(Schema.String).pipe(Schema.optional),
31+
disallowedTools: Schema.Array(Schema.String).pipe(Schema.optional),
2532
}) {}

packages/core/src/config/compaction.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,18 @@ export class Keep extends Schema.Class<Keep>("ConfigV2.Compaction.Keep")({
77
tokens: NonNegativeInt.pipe(Schema.optional),
88
}) {}
99

10+
export class Levels extends Schema.Class<Levels>("ConfigV2.Compaction.Levels")({
11+
l2_trigger: Schema.Number.pipe(Schema.optional),
12+
l3_trigger: Schema.Number.pipe(Schema.optional),
13+
l4_trigger: Schema.Number.pipe(Schema.optional),
14+
l5_trigger: Schema.Number.pipe(Schema.optional),
15+
l1_max_chars: NonNegativeInt.pipe(Schema.optional),
16+
}) {}
17+
1018
export class Info extends Schema.Class<Info>("ConfigV2.Compaction")({
1119
auto: Schema.Boolean.pipe(Schema.optional),
1220
prune: Schema.Boolean.pipe(Schema.optional),
1321
keep: Keep.pipe(Schema.optional),
1422
buffer: NonNegativeInt.pipe(Schema.optional),
23+
levels: Levels.pipe(Schema.optional),
1524
}) {}

packages/core/src/config/plugin/agent.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * as ConfigAgentPlugin from "./agent"
22

33
import { define } from "../../plugin/internal"
44
import path from "path"
5+
import os from "os"
56
import { Effect, Option, Schema } from "effect"
67
import { AgentV2 } from "../../agent"
78
import { Config } from "../../config"
@@ -41,6 +42,9 @@ const agentKeys = new Set([
4142
"steps",
4243
"disabled",
4344
"permissions",
45+
"model_preference",
46+
"tools",
47+
"disallowedTools",
4448
])
4549

4650
export const Plugin = define({
@@ -108,6 +112,27 @@ export const Plugin = define({
108112
if (item.permissions !== undefined) {
109113
agent.permissions.push(...expandPermissions(item.permissions, global.home))
110114
}
115+
if (item.model_preference !== undefined) {
116+
const continuation = item.model_preference.continuation
117+
if (continuation !== undefined) {
118+
const parsed = ModelV2.parse(continuation)
119+
const mutable = agent as typeof agent & { model_preference?: { continuation?: { id: string; providerID: string } } }
120+
mutable.model_preference = {
121+
continuation: { id: parsed.modelID, providerID: parsed.providerID },
122+
}
123+
}
124+
}
125+
if (item.tools !== undefined) {
126+
agent.permissions.push({ action: "*", resource: "*", effect: "deny" })
127+
for (const tool of item.tools) {
128+
agent.permissions.push({ action: tool, resource: "*", effect: "allow" })
129+
}
130+
}
131+
if (item.disallowedTools !== undefined) {
132+
for (const tool of item.disallowedTools) {
133+
agent.permissions.push({ action: tool, resource: "*", effect: "deny" })
134+
}
135+
}
111136
})
112137
}
113138
}
@@ -150,6 +175,13 @@ function discover(fs: FSUtil.Interface, directory: string) {
150175
)
151176
}
152177

178+
function substituteTemplateVariables(text: string, cwd: string): string {
179+
return text
180+
.replaceAll("${cwd}", cwd)
181+
.replaceAll("${os}", os.platform())
182+
.replaceAll("${shell}", process.env.SHELL ?? "sh")
183+
}
184+
153185
function decode(file: { directory: string; filepath: string; primary: boolean }, content: string) {
154186
const markdown = ConfigMarkdown.parseOption(content)
155187
if (!markdown) return
@@ -158,7 +190,7 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
158190
.replaceAll("\\", "/")
159191
.replace(/^(agent|agents|mode|modes)\//, "")
160192
.replace(/\.md$/, "")
161-
const body = markdown.content.trim()
193+
const body = substituteTemplateVariables(markdown.content.trim(), file.directory)
162194
const legacy = Object.keys(markdown.data).some((key) => !agentKeys.has(key))
163195
const agent = Option.getOrUndefined(
164196
legacy
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export * as MemoryContext from "./context"
2+
3+
import { Effect, Layer, Schema } from "effect"
4+
import { SystemContext } from "../system-context"
5+
import { SystemContextRegistry } from "../system-context/registry"
6+
import { makeLocationNode } from "../effect/app-node"
7+
import { getIndex } from "./store"
8+
9+
const formatMemories = (
10+
index: ReadonlyArray<{ id: string; category: string; title: string; keywords: ReadonlyArray<string> }>,
11+
): string => {
12+
if (index.length === 0) return ""
13+
const lines = index.map((m) => `- [${m.category}] ${m.title} (keywords: ${m.keywords.join(", ")})`)
14+
return `<memories>\n${lines.join("\n")}\n</memories>`
15+
}
16+
17+
const layer = Layer.effectDiscard(
18+
Effect.gen(function* () {
19+
const registry = yield* SystemContextRegistry.Service
20+
21+
const context = SystemContext.make({
22+
key: SystemContext.Key.make("core/memory"),
23+
codec: Schema.toCodecJson(Schema.String),
24+
load: Effect.promise(getIndex).pipe(
25+
Effect.map((index) => {
26+
if (index.length === 0) return SystemContext.unavailable
27+
return formatMemories(index)
28+
}),
29+
),
30+
baseline: (text) => text,
31+
update: (_previous, text) => text,
32+
})
33+
34+
yield* registry.register({ key: SystemContext.Key.make("core/memory"), load: Effect.succeed(context) })
35+
}),
36+
)
37+
38+
export const node = makeLocationNode({
39+
name: "memory-context",
40+
layer,
41+
deps: [SystemContextRegistry.node],
42+
})

packages/core/src/memory/dream.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { loadMemories, saveMemories, type Memory } from "./store"
2+
3+
const DREAM_DIR = `${process.env.HOME}/.config/opencode/memory`
4+
const DREAM_FILE = `${DREAM_DIR}/last-dream.json`
5+
const CONSOLIDATION_INTERVAL_MS = 24 * 60 * 60 * 1000
6+
7+
const readLastDream = async (): Promise<number> => {
8+
const file = Bun.file(DREAM_FILE)
9+
if (!(await file.exists())) return 0
10+
const data = await file.json()
11+
return data.timestamp ?? 0
12+
}
13+
14+
const writeLastDream = async (timestamp: number): Promise<void> => {
15+
await Bun.write(DREAM_FILE, JSON.stringify({ timestamp }, null, 2))
16+
}
17+
18+
export const shouldConsolidate = async (): Promise<boolean> => {
19+
const last = await readLastDream()
20+
return Date.now() - last >= CONSOLIDATION_INTERVAL_MS
21+
}
22+
23+
const buildPrompt = (existing: ReadonlyArray<Memory>, summaries: ReadonlyArray<string>): string => {
24+
const existingText = existing.length === 0
25+
? "(no existing memories)"
26+
: existing.map((m) => `- [${m.category}] ${m.title}: ${m.content}`).join("\n")
27+
28+
const summariesText = summaries.join("\n---\n")
29+
30+
return `You are a memory consolidation assistant. Extract and maintain important facts from session summaries.
31+
32+
EXISTING MEMORIES:
33+
${existingText}
34+
35+
NEW SESSION SUMMARIES:
36+
${summariesText}
37+
38+
INSTRUCTIONS:
39+
1. Extract new facts, preferences, or project details from the summaries
40+
2. Merge duplicates - if a fact already exists, update it rather than creating a new one
41+
3. Remove outdated information that conflicts with newer data
42+
4. Categorize each memory: user (preferences/habits), feedback (corrections), project (tech details), reference (facts to remember)
43+
44+
Respond with a JSON array of memory objects:
45+
[{"category": "user|feedback|project|reference", "title": "short title", "content": "detailed content", "keywords": ["key1", "key2"]}]
46+
47+
If no new memories should be extracted, respond with: []`
48+
}
49+
50+
export const consolidate = async (sessionSummaries: ReadonlyArray<string>): Promise<void> => {
51+
if (sessionSummaries.length === 0) return
52+
53+
const existing = await loadMemories()
54+
const _prompt = buildPrompt(existing, sessionSummaries)
55+
56+
try {
57+
const response = await Bun.stdin.text() // placeholder - in real usage, call LLM
58+
const parsed = JSON.parse(response) as ReadonlyArray<{
59+
category: Memory["category"]
60+
title: string
61+
content: string
62+
keywords: ReadonlyArray<string>
63+
}>
64+
65+
const now = new Date().toISOString()
66+
const newMemories: ReadonlyArray<Memory> = parsed.map((item) => ({
67+
id: crypto.randomUUID(),
68+
category: item.category,
69+
title: item.title,
70+
content: item.content,
71+
keywords: item.keywords,
72+
created_at: now,
73+
updated_at: now,
74+
}))
75+
76+
const merged = [...existing, ...newMemories].slice(-200)
77+
await saveMemories(merged)
78+
await writeLastDream(Date.now())
79+
} catch {
80+
// Silently fail - don't crash on consolidation errors
81+
}
82+
}
83+
84+
export const Dream = {
85+
shouldConsolidate,
86+
consolidate,
87+
}

packages/core/src/memory/store.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
export interface Memory {
2+
readonly id: string
3+
readonly category: "user" | "feedback" | "project" | "reference"
4+
readonly title: string
5+
readonly content: string
6+
readonly keywords: ReadonlyArray<string>
7+
readonly created_at: string
8+
readonly updated_at: string
9+
}
10+
11+
const MEMORY_DIR = `${process.env.HOME}/.config/opencode/memory`
12+
const MEMORY_FILE = `${MEMORY_DIR}/memories.json`
13+
const MAX_MEMORIES = 200
14+
15+
const ensureDir = async () => {
16+
await Bun.write(`${MEMORY_DIR}/.gitkeep`, "")
17+
}
18+
19+
export const loadMemories = async (): Promise<ReadonlyArray<Memory>> => {
20+
const file = Bun.file(MEMORY_FILE)
21+
if (!(await file.exists())) return []
22+
const data = await file.json()
23+
return data as ReadonlyArray<Memory>
24+
}
25+
26+
export const saveMemories = async (memories: ReadonlyArray<Memory>): Promise<void> => {
27+
await ensureDir()
28+
await Bun.write(MEMORY_FILE, JSON.stringify(memories, null, 2))
29+
}
30+
31+
export const addMemory = async (
32+
category: Memory["category"],
33+
title: string,
34+
content: string,
35+
keywords: ReadonlyArray<string>,
36+
): Promise<Memory> => {
37+
const memories = await loadMemories()
38+
const now = new Date().toISOString()
39+
const memory: Memory = {
40+
id: crypto.randomUUID(),
41+
category,
42+
title,
43+
content,
44+
keywords,
45+
created_at: now,
46+
updated_at: now,
47+
}
48+
const updated = [...memories, memory].slice(-MAX_MEMORIES)
49+
await saveMemories(updated)
50+
return memory
51+
}
52+
53+
export const updateMemory = async (id: string, updates: Partial<Omit<Memory, "id" | "created_at">>): Promise<void> => {
54+
const memories = await loadMemories()
55+
const updated = memories.map((m) => {
56+
if (m.id !== id) return m
57+
return { ...m, ...updates, updated_at: new Date().toISOString() }
58+
})
59+
await saveMemories(updated)
60+
}
61+
62+
export const deleteMemory = async (id: string): Promise<void> => {
63+
const memories = await loadMemories()
64+
await saveMemories(memories.filter((m) => m.id !== id))
65+
}
66+
67+
export const getIndex = async (): Promise<ReadonlyArray<Pick<Memory, "id" | "category" | "title" | "keywords">>> => {
68+
const memories = await loadMemories()
69+
return memories.map((m) => ({
70+
id: m.id,
71+
category: m.category,
72+
title: m.title,
73+
keywords: m.keywords,
74+
}))
75+
}

0 commit comments

Comments
 (0)