chore(observability): merge v2
This commit is contained in:
commit
a18e5de4af
435 changed files with 18249 additions and 12191 deletions
|
|
@ -8,6 +8,8 @@ import { State } from "./state"
|
|||
|
||||
export const ID = Agent.ID
|
||||
export type ID = typeof ID.Type
|
||||
export const Name = Agent.Name
|
||||
export type Name = Agent.Name
|
||||
export const defaultID = ID.make("build")
|
||||
|
||||
export const Color = Agent.Color
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
JSONValue,
|
||||
LanguageModelV3,
|
||||
LanguageModelV3CallOptions,
|
||||
LanguageModelV3FinishReason,
|
||||
LanguageModelV3FunctionTool,
|
||||
LanguageModelV3Message,
|
||||
LanguageModelV3Prompt,
|
||||
|
|
@ -304,6 +305,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
|
|||
const route: AnyRoute = {
|
||||
id: `ai-sdk:${ProviderV2.packageName(info.package) ?? "unknown"}`,
|
||||
provider: ProviderID.make(info.providerID),
|
||||
providerMetadataKey: optionKey,
|
||||
protocol: "ai-sdk",
|
||||
endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }),
|
||||
auth: Auth.none,
|
||||
|
|
@ -416,7 +418,7 @@ function assistantPart(part: ContentPart): AssistantContent {
|
|||
case "media":
|
||||
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
|
||||
case "reasoning":
|
||||
return [{ type: "reasoning", text: part.text }]
|
||||
return [{ type: "reasoning", text: part.text, providerOptions: providerOptions(part.providerMetadata) }]
|
||||
case "tool-call":
|
||||
return [
|
||||
{
|
||||
|
|
@ -425,6 +427,7 @@ function assistantPart(part: ContentPart): AssistantContent {
|
|||
toolName: part.name,
|
||||
input: part.input,
|
||||
providerExecuted: part.providerExecuted,
|
||||
providerOptions: providerOptions(part.providerMetadata),
|
||||
},
|
||||
]
|
||||
case "tool-result":
|
||||
|
|
@ -440,6 +443,7 @@ function toolResultPart(part: ContentPart): ToolResultContent[] {
|
|||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
output: toolOutput(part.result),
|
||||
providerOptions: providerOptions(part.providerMetadata),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
@ -624,8 +628,8 @@ function usage(input: Extract<LanguageModelV3StreamPart, { type: "finish" }>["us
|
|||
return Object.values(output).some((value) => value !== undefined) ? output : undefined
|
||||
}
|
||||
|
||||
function finishReason(value: unknown): FinishReason {
|
||||
return Schema.is(FinishReason)(value) ? value : "unknown"
|
||||
function finishReason(value: LanguageModelV3FinishReason): FinishReason {
|
||||
return value.unified === "other" ? "unknown" : value.unified
|
||||
}
|
||||
|
||||
function providerMetadata(value: unknown) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ModelV2 } from "../../model"
|
||||
|
|
@ -91,8 +92,8 @@ export const Plugin = define({
|
|||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: {
|
||||
read: cost.cache?.read ?? 0,
|
||||
write: cost.cache?.write ?? 0,
|
||||
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
|
||||
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export * as ConfigProvider from "./provider"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { ModelV2 } from "../model"
|
||||
|
||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||
|
|
@ -17,8 +18,8 @@ export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")(
|
|||
}) {}
|
||||
|
||||
class Cache extends Schema.Class<Cache>("ConfigV2.Model.Cost.Cache")({
|
||||
read: Schema.Finite.pipe(Schema.optional),
|
||||
write: Schema.Finite.pipe(Schema.optional),
|
||||
read: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||
write: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Cost extends Schema.Class<Cost>("ConfigV2.Model.Cost")({
|
||||
|
|
@ -26,8 +27,8 @@ class Cost extends Schema.Class<Cost>("ConfigV2.Model.Cost")({
|
|||
type: Schema.Literal("context"),
|
||||
size: Schema.Int,
|
||||
}).pipe(Schema.optional),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
input: Money.USDPerMillionTokens,
|
||||
output: Money.USDPerMillionTokens,
|
||||
cache: Cache.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
|
|
|
|||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -48,5 +48,6 @@ export const migrations = (
|
|||
import("./migration/20260705180000_rename_instructions"),
|
||||
import("./migration/20260706223930_add-session-fork"),
|
||||
import("./migration/20260707010146_durable_session_inbox"),
|
||||
import("./migration/20260707120000_migrate_prelaunch_v2_state"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString)
|
||||
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
|
||||
|
||||
export default {
|
||||
id: "20260707120000_migrate_prelaunch_v2_state",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(
|
||||
sql`DELETE FROM session_message WHERE type = 'compaction' AND json_extract(data, '$.status') = 'queued'`,
|
||||
)
|
||||
const messages = yield* tx.all<{ id: string; type: string; data: string }>(
|
||||
sql`SELECT id, type, data FROM session_message WHERE type IN ('skill', 'shell', 'assistant', 'compaction', 'synthetic')`,
|
||||
)
|
||||
for (const row of messages) {
|
||||
const data = object(decodeJson(row.data))
|
||||
yield* tx.run(
|
||||
sql`UPDATE session_message SET data = ${JSON.stringify(messageData(row.type, data))} WHERE id = ${row.id}`,
|
||||
)
|
||||
}
|
||||
|
||||
yield* tx.run(sql`DELETE FROM event WHERE type = 'session.compaction.delta.1'`)
|
||||
const events = yield* tx.all<{ id: string; aggregateID: string; seq: number; type: string; data: string }>(sql`
|
||||
SELECT id, aggregate_id as aggregateID, seq, type, data
|
||||
FROM event
|
||||
WHERE type IN (
|
||||
'session.skill.activated.1',
|
||||
'session.skill.activated.2',
|
||||
'session.compaction.started.1',
|
||||
'session.compaction.started.2',
|
||||
'session.compaction.ended.1',
|
||||
'session.compaction.failed.1',
|
||||
'session.compaction.failed.2',
|
||||
'session.revert.staged.1',
|
||||
'session.revert.staged.2'
|
||||
)
|
||||
ORDER BY aggregate_id, seq
|
||||
`)
|
||||
const compactionReasons = new Map<string, "auto" | "manual">()
|
||||
for (const row of events) {
|
||||
const data = object(decodeJson(row.data))
|
||||
if (row.type.startsWith("session.compaction.ended.")) {
|
||||
compactionReasons.delete(row.aggregateID)
|
||||
continue
|
||||
}
|
||||
const event = eventData(row.type, data, compactionReasons.get(row.aggregateID))
|
||||
if (row.type.startsWith("session.compaction.started."))
|
||||
compactionReasons.set(row.aggregateID, event.data.reason === "auto" ? "auto" : "manual")
|
||||
if (row.type.startsWith("session.compaction.failed.")) compactionReasons.delete(row.aggregateID)
|
||||
yield* tx.run(
|
||||
sql`UPDATE event SET type = ${event.type}, data = ${JSON.stringify(event.data)} WHERE id = ${row.id}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
||||
function messageData(type: string, data: Record<string, unknown>) {
|
||||
if (type === "skill")
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
skill: data.skill ?? data.id ?? data.name,
|
||||
name: data.name,
|
||||
text: data.text,
|
||||
})
|
||||
if (type === "shell") {
|
||||
const shell = object(data.shell)
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
shellID: data.shellID ?? shell.id,
|
||||
command: data.command ?? shell.command,
|
||||
status: data.status ?? shell.status,
|
||||
exit: data.exit ?? shell.exit,
|
||||
output: data.output,
|
||||
})
|
||||
}
|
||||
if (type === "assistant")
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
agent: data.agent,
|
||||
model: data.model,
|
||||
content: Array.isArray(data.content) ? data.content.map(assistantContent) : data.content,
|
||||
snapshot: data.snapshot,
|
||||
finish: data.finish,
|
||||
cost: data.cost,
|
||||
tokens: data.tokens,
|
||||
error: data.error,
|
||||
retry: data.retry,
|
||||
})
|
||||
if (type === "compaction") {
|
||||
if (data.status === "failed")
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
status: data.status,
|
||||
reason: data.reason,
|
||||
error: data.error ?? genericCompactionError,
|
||||
})
|
||||
return defined({
|
||||
metadata: data.metadata,
|
||||
time: data.time,
|
||||
status: data.status,
|
||||
reason: data.reason,
|
||||
summary: data.summary,
|
||||
recent: data.recent,
|
||||
})
|
||||
}
|
||||
if (type === "synthetic")
|
||||
return defined({ metadata: data.metadata, time: data.time, text: data.text, description: data.description })
|
||||
const { sessionID: _, ...current } = data
|
||||
return current
|
||||
}
|
||||
|
||||
function assistantContent(value: unknown) {
|
||||
const content = object(value)
|
||||
if (content.type === "text") return defined({ type: content.type, text: content.text })
|
||||
if (content.type === "reasoning")
|
||||
return defined({ type: content.type, text: content.text, state: content.state, time: content.time })
|
||||
if (content.type !== "tool") return content
|
||||
return defined({
|
||||
type: content.type,
|
||||
id: content.id,
|
||||
name: content.name,
|
||||
executed: content.executed,
|
||||
providerState: content.providerState,
|
||||
providerResultState: content.providerResultState,
|
||||
state: toolState(content.state),
|
||||
time: content.time,
|
||||
})
|
||||
}
|
||||
|
||||
function toolState(value: unknown) {
|
||||
const state = object(value)
|
||||
if (state.status === "pending" || state.status === "streaming")
|
||||
return defined({ status: "streaming", input: state.input })
|
||||
if (state.status === "running")
|
||||
return defined({ status: state.status, input: state.input, structured: state.structured, content: state.content })
|
||||
if (state.status === "completed")
|
||||
return defined({
|
||||
status: state.status,
|
||||
input: state.input,
|
||||
structured: state.structured,
|
||||
content: state.content,
|
||||
result: state.result,
|
||||
})
|
||||
if (state.status === "error")
|
||||
return defined({
|
||||
status: state.status,
|
||||
input: state.input,
|
||||
structured: state.structured,
|
||||
content: state.content,
|
||||
error: state.error,
|
||||
result: state.result,
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
function eventData(type: string, data: Record<string, unknown>, compactionReason?: "auto" | "manual") {
|
||||
if (type.startsWith("session.skill.activated."))
|
||||
return {
|
||||
type: "session.skill.activated.1",
|
||||
data: defined({ sessionID: data.sessionID, id: data.id ?? data.name, name: data.name, text: data.text }),
|
||||
}
|
||||
if (type.startsWith("session.compaction.started."))
|
||||
return {
|
||||
type: "session.compaction.started.1",
|
||||
data: defined({
|
||||
sessionID: data.sessionID,
|
||||
reason: data.reason,
|
||||
recent: data.recent ?? "",
|
||||
inputID: data.inputID,
|
||||
}),
|
||||
}
|
||||
if (type.startsWith("session.compaction.failed."))
|
||||
return {
|
||||
type: "session.compaction.failed.1",
|
||||
data: defined({
|
||||
sessionID: data.sessionID,
|
||||
reason: data.reason ?? compactionReason ?? "manual",
|
||||
error: data.error ?? genericCompactionError,
|
||||
inputID: data.inputID,
|
||||
}),
|
||||
}
|
||||
const revert = object(data.revert)
|
||||
return {
|
||||
type: "session.revert.staged.1",
|
||||
data: defined({
|
||||
sessionID: data.sessionID,
|
||||
revert: defined({
|
||||
messageID: revert.messageID,
|
||||
partID: revert.partID,
|
||||
snapshot: revert.snapshot,
|
||||
files: Array.isArray(revert.files)
|
||||
? revert.files.map((value) => {
|
||||
const file = object(value)
|
||||
return defined({
|
||||
file: file.file ?? file.path,
|
||||
patch: file.patch,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
status: file.status,
|
||||
})
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const genericCompactionError = {
|
||||
type: "compaction.failed",
|
||||
message: "Compaction failed before recording an error",
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return isObject(value) ? value : {}
|
||||
}
|
||||
|
||||
function defined(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined))
|
||||
}
|
||||
|
|
@ -200,6 +200,6 @@ export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.no
|
|||
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
|
||||
// TODO: Add snapshots / undo after V2 snapshot design exists.
|
||||
// TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists.
|
||||
// TODO: Design multi-file transactions / rollback if apply_patch needs atomic edits.
|
||||
// TODO: Design multi-file transactions / rollback if patch needs atomic edits.
|
||||
// Until then, edits are sequential and report partial application.
|
||||
// TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as File from "./file"
|
||||
|
||||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
|
||||
export const Diff = Revert.FileDiff
|
||||
export const Diff = FileDiff.Info
|
||||
export type Diff = typeof Diff.Type
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const layer = Layer.effect(
|
|||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
const home = path.resolve(location.directory) === path.resolve(os.homedir())
|
||||
|
||||
if (!home) {
|
||||
if (!home && location.vcs) {
|
||||
yield* watcher
|
||||
.subscribe({
|
||||
path: location.directory,
|
||||
|
|
|
|||
|
|
@ -606,7 +606,7 @@ const layer = Layer.effect(
|
|||
file,
|
||||
])).text
|
||||
return {
|
||||
path: file,
|
||||
file,
|
||||
status,
|
||||
additions: binary ? 0 : Number(stats[0] ?? 0),
|
||||
deletions: binary ? 0 : Number(stats[1] ?? 0),
|
||||
|
|
|
|||
|
|
@ -150,31 +150,44 @@ export type LocationError = LayerNode.Error<typeof locationServices>
|
|||
export function buildLocationServiceMap(
|
||||
replacements: LayerNode.Replacements = [],
|
||||
): Layer.Layer<LocationServiceMap.Service> {
|
||||
// Structural Equal is own-key-set sensitive, so `{ directory }` (schema-decoded
|
||||
// payloads omit optional keys) and `{ directory, workspaceID: undefined }` are
|
||||
// different RcMap keys. The RcMap caches by the raw key before the build
|
||||
// callback runs, so canonicalize at the map boundary to the key-present shape.
|
||||
const canonical = (ref: Location.Ref) => Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID })
|
||||
return Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
(ref: Location.Ref) => {
|
||||
const startedAt = performance.now()
|
||||
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
|
||||
// Apply replacements during hoist, not afterward: replacements can
|
||||
// introduce new tagged dependencies (Location.boundNode depends on
|
||||
// Project), and the hoist walk is the only pass that can still slice
|
||||
// those back out.
|
||||
const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
|
||||
Effect.map(
|
||||
LayerMap.make(
|
||||
(ref: Location.Ref) => {
|
||||
const startedAt = performance.now()
|
||||
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
|
||||
// Apply replacements during hoist, not afterward: replacements can
|
||||
// introduce new tagged dependencies (Location.boundNode depends on
|
||||
// Project), and the hoist walk is the only pass that can still slice
|
||||
// those back out.
|
||||
const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
|
||||
|
||||
return LayerNode.compile(location.node).pipe(
|
||||
Layer.fresh,
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("location services booted", {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}),
|
||||
),
|
||||
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||
)
|
||||
},
|
||||
{ idleTimeToLive: "60 minutes" },
|
||||
return LayerNode.compile(location.node).pipe(
|
||||
Layer.fresh,
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("location services booted", {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
}),
|
||||
),
|
||||
Layer.provide(LayerNode.compile(location.hoisted)),
|
||||
)
|
||||
},
|
||||
{ idleTimeToLive: "60 minutes" },
|
||||
),
|
||||
(inner) => ({
|
||||
...inner,
|
||||
get: (ref: Location.Ref) => inner.get(canonical(ref)),
|
||||
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export * as MCPClient from "./client"
|
|||
import path from "node:path"
|
||||
import { execFile } from "node:child_process"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
ListToolsResultSchema,
|
||||
PromptListChangedNotificationSchema,
|
||||
PromptSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
type LoggingMessageNotification,
|
||||
LoggingMessageNotificationSchema,
|
||||
ToolListChangedNotificationSchema,
|
||||
|
|
@ -68,11 +69,13 @@ export interface ToolDefinition {
|
|||
export interface PromptDefinition {
|
||||
readonly name: string
|
||||
readonly description: string | undefined
|
||||
readonly arguments: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly description: string | undefined
|
||||
readonly required: boolean | undefined
|
||||
}> | undefined
|
||||
readonly arguments:
|
||||
| ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly description: string | undefined
|
||||
readonly required: boolean | undefined
|
||||
}>
|
||||
| undefined
|
||||
}
|
||||
|
||||
export interface PromptMessage {
|
||||
|
|
@ -84,6 +87,28 @@ export interface PromptResult {
|
|||
readonly messages: ReadonlyArray<PromptMessage>
|
||||
}
|
||||
|
||||
export interface ResourceDefinition {
|
||||
readonly name: string
|
||||
readonly uri: string
|
||||
readonly description: string | undefined
|
||||
readonly mimeType: string | undefined
|
||||
}
|
||||
|
||||
export interface ResourceTemplateDefinition {
|
||||
readonly name: string
|
||||
readonly uriTemplate: string
|
||||
readonly description: string | undefined
|
||||
readonly mimeType: string | undefined
|
||||
}
|
||||
|
||||
export type ResourceContentPart =
|
||||
| { readonly type: "text"; readonly uri: string; readonly text: string; readonly mimeType: string | undefined }
|
||||
| { readonly type: "blob"; readonly uri: string; readonly blob: string; readonly mimeType: string | undefined }
|
||||
|
||||
export interface ReadResourceResult {
|
||||
readonly contents: ReadonlyArray<ResourceContentPart>
|
||||
}
|
||||
|
||||
export type CallToolContent =
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "media"; readonly data: string; readonly mimeType: string }
|
||||
|
|
@ -124,6 +149,12 @@ export interface Connection {
|
|||
readonly tools: () => Effect.Effect<ToolDefinition[], Error>
|
||||
/** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */
|
||||
readonly prompts: () => Effect.Effect<PromptDefinition[], Error>
|
||||
/** Lists the server's resources; returns [] when the server doesn't advertise resource support. */
|
||||
readonly resources: () => Effect.Effect<ResourceDefinition[], Error>
|
||||
/** Lists the server's resource templates; returns [] when the server doesn't advertise resource support. */
|
||||
readonly resourceTemplates: () => Effect.Effect<ResourceTemplateDefinition[], Error>
|
||||
/** Reads one resource; returns undefined when the server doesn't advertise resource support. */
|
||||
readonly readResource: (input: { readonly uri: string }) => Effect.Effect<ReadResourceResult | undefined, Error>
|
||||
/** Invokes a prompt on the server. Interruption aborts the in-flight request. */
|
||||
readonly prompt: (input: {
|
||||
readonly name: string
|
||||
|
|
@ -141,6 +172,8 @@ export interface Connection {
|
|||
readonly onToolsChanged: (callback: () => void) => void
|
||||
/** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */
|
||||
readonly onPromptsChanged: (callback: () => void) => void
|
||||
/** Registers a callback fired when the server announces its resource catalog changed. */
|
||||
readonly onResourcesChanged: (callback: () => void) => void
|
||||
}
|
||||
|
||||
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
|
||||
|
|
@ -168,7 +201,8 @@ export const connect = Effect.fnUntraced(function* (
|
|||
},
|
||||
})
|
||||
}
|
||||
if (!URL.canParse(config.url)) return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
|
||||
if (!URL.canParse(config.url))
|
||||
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
|
||||
return new StreamableHTTPClientTransport(new URL(config.url), {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
authProvider,
|
||||
|
|
@ -202,10 +236,7 @@ export const connect = Effect.fnUntraced(function* (
|
|||
}).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(exit)) {
|
||||
yield* Effect.addFinalizer(() =>
|
||||
cleanupStdioDescendants(transport).pipe(
|
||||
Effect.andThen(Effect.promise(() => client.close())),
|
||||
Effect.ignore,
|
||||
),
|
||||
cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => client.close())), Effect.ignore),
|
||||
)
|
||||
const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT
|
||||
const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT
|
||||
|
|
@ -257,7 +288,9 @@ export const connect = Effect.fnUntraced(function* (
|
|||
),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}).pipe(
|
||||
Effect.tapError((error) => Effect.logWarning("failed to list MCP prompts", { server, error: error.message })),
|
||||
Effect.tapError((error) =>
|
||||
Effect.logWarning("failed to list MCP prompts", { server, error: error.message }),
|
||||
),
|
||||
)
|
||||
return prompts.map((prompt) => ({
|
||||
name: prompt.name,
|
||||
|
|
@ -269,6 +302,74 @@ export const connect = Effect.fnUntraced(function* (
|
|||
})),
|
||||
}))
|
||||
}),
|
||||
resources: () =>
|
||||
Effect.gen(function* () {
|
||||
if (!client.getServerCapabilities()?.resources) return []
|
||||
const resources = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
paginate(
|
||||
(cursor) =>
|
||||
client.listResources(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
|
||||
(result) => result.resources,
|
||||
),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}).pipe(
|
||||
Effect.tapError((error) =>
|
||||
Effect.logWarning("failed to list MCP resources", { server, error: error.message }),
|
||||
),
|
||||
)
|
||||
return resources.map((resource) => ({
|
||||
name: resource.name,
|
||||
uri: resource.uri,
|
||||
description: resource.description,
|
||||
mimeType: resource.mimeType,
|
||||
}))
|
||||
}),
|
||||
resourceTemplates: () =>
|
||||
Effect.gen(function* () {
|
||||
if (!client.getServerCapabilities()?.resources) return []
|
||||
const templates = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
paginate(
|
||||
(cursor) =>
|
||||
client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, {
|
||||
timeout: catalogTimeout,
|
||||
}),
|
||||
(result) => result.resourceTemplates,
|
||||
),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}).pipe(
|
||||
Effect.tapError((error) =>
|
||||
Effect.logWarning("failed to list MCP resource templates", { server, error: error.message }),
|
||||
),
|
||||
)
|
||||
return templates.map((template) => ({
|
||||
name: template.name,
|
||||
uriTemplate: template.uriTemplate,
|
||||
description: template.description,
|
||||
mimeType: template.mimeType,
|
||||
}))
|
||||
}),
|
||||
readResource: (input) =>
|
||||
Effect.gen(function* () {
|
||||
if (!client.getServerCapabilities()?.resources) return undefined
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: (signal) => client.readResource({ uri: input.uri }, { signal, timeout: executionTimeout }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}).pipe(
|
||||
Effect.tapError((error) =>
|
||||
Effect.logWarning("failed to read MCP resource", { server, uri: input.uri, error: error.message }),
|
||||
),
|
||||
)
|
||||
return {
|
||||
contents: result.contents.map(
|
||||
(part): ResourceContentPart =>
|
||||
"text" in part
|
||||
? { type: "text", uri: part.uri, text: part.text, mimeType: part.mimeType }
|
||||
: { type: "blob", uri: part.uri, blob: part.blob, mimeType: part.mimeType },
|
||||
),
|
||||
}
|
||||
}),
|
||||
prompt: (input) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
|
|
@ -328,13 +429,14 @@ export const connect = Effect.fnUntraced(function* (
|
|||
if (!client.getServerCapabilities()?.prompts?.listChanged) return
|
||||
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback())
|
||||
},
|
||||
onResourcesChanged: (callback) => {
|
||||
if (!client.getServerCapabilities()?.resources?.listChanged) return
|
||||
client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => callback())
|
||||
},
|
||||
} satisfies Connection
|
||||
}
|
||||
|
||||
yield* cleanupStdioDescendants(transport).pipe(
|
||||
Effect.andThen(Effect.promise(() => transport.close())),
|
||||
Effect.ignore,
|
||||
)
|
||||
yield* cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => transport.close())), Effect.ignore)
|
||||
const error = Cause.squash(exit.cause)
|
||||
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
|
||||
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
|
||||
|
|
|
|||
|
|
@ -83,48 +83,16 @@ export class PromptResult extends Schema.Class<PromptResult>("MCP.PromptResult")
|
|||
messages: Schema.Array(PromptMessage),
|
||||
}) {}
|
||||
|
||||
export class Resource extends Schema.Class<Resource>("MCP.Resource")({
|
||||
server: ServerName,
|
||||
name: Schema.String,
|
||||
uri: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mimeType: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ResourceTemplate extends Schema.Class<ResourceTemplate>("MCP.ResourceTemplate")({
|
||||
server: ServerName,
|
||||
name: Schema.String,
|
||||
uriTemplate: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mimeType: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ResourceCatalog extends Schema.Class<ResourceCatalog>("MCP.ResourceCatalog")({
|
||||
resources: Schema.Array(Resource),
|
||||
templates: Schema.Array(ResourceTemplate),
|
||||
}) {}
|
||||
|
||||
export const ResourceContentPart = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
uri: Schema.String,
|
||||
text: Schema.String,
|
||||
mimeType: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("blob"),
|
||||
uri: Schema.String,
|
||||
blob: Schema.String,
|
||||
mimeType: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type ResourceContentPart = typeof ResourceContentPart.Type
|
||||
|
||||
export class ResourceContent extends Schema.Class<ResourceContent>("MCP.ResourceContent")({
|
||||
server: ServerName,
|
||||
uri: Schema.String,
|
||||
contents: Schema.Array(ResourceContentPart),
|
||||
}) {}
|
||||
export const Resource = Mcp.Resource
|
||||
export type Resource = Mcp.Resource
|
||||
export const ResourceTemplate = Mcp.ResourceTemplate
|
||||
export type ResourceTemplate = Mcp.ResourceTemplate
|
||||
export const ResourceCatalog = Mcp.ResourceCatalog
|
||||
export type ResourceCatalog = Mcp.ResourceCatalog
|
||||
export const ResourceContentPart = Mcp.ResourceContentPart
|
||||
export type ResourceContentPart = Mcp.ResourceContentPart
|
||||
export const ResourceContent = Mcp.ResourceContent
|
||||
export type ResourceContent = Mcp.ResourceContent
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
|
||||
server: ServerName,
|
||||
|
|
@ -415,6 +383,24 @@ export const layer = Layer.effect(
|
|||
),
|
||||
})
|
||||
|
||||
const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) =>
|
||||
Resource.make({
|
||||
server,
|
||||
name: def.name,
|
||||
uri: def.uri,
|
||||
description: def.description,
|
||||
mimeType: def.mimeType,
|
||||
})
|
||||
|
||||
const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) =>
|
||||
ResourceTemplate.make({
|
||||
server,
|
||||
name: def.name,
|
||||
uriTemplate: def.uriTemplate,
|
||||
description: def.description,
|
||||
mimeType: def.mimeType,
|
||||
})
|
||||
|
||||
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||
connection.tools().pipe(
|
||||
Effect.map((defs) => {
|
||||
|
|
@ -443,6 +429,7 @@ export const layer = Layer.effect(
|
|||
entry.prompts = undefined
|
||||
entry.status = { status: "failed", error: "Connection closed" }
|
||||
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
|
||||
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
|
||||
fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore))
|
||||
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
|
||||
})
|
||||
|
|
@ -458,6 +445,10 @@ export const layer = Layer.effect(
|
|||
connection.onPromptsChanged(() => {
|
||||
fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore))
|
||||
})
|
||||
connection.onResourcesChanged(() => {
|
||||
if (entry.client !== connection) return
|
||||
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
|
||||
})
|
||||
}
|
||||
|
||||
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
||||
|
|
@ -501,6 +492,7 @@ export const layer = Layer.effect(
|
|||
// after the initial registration sweep and emits no list-changed notification would otherwise
|
||||
// stay invisible to the model.
|
||||
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore))
|
||||
return
|
||||
|
|
@ -557,11 +549,6 @@ export const layer = Layer.effect(
|
|||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
const gate = Effect.fnUntraced(function* (server: ServerName | string) {
|
||||
const target = yield* requireServer(server)
|
||||
yield* Deferred.await(target.entry.startup)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
servers: Effect.fn("MCP.servers")(function* () {
|
||||
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
|
||||
|
|
@ -637,11 +624,54 @@ export const layer = Layer.effect(
|
|||
}),
|
||||
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
|
||||
yield* whenAllReady
|
||||
return new ResourceCatalog({ resources: [], templates: [] })
|
||||
const catalogs = yield* Effect.forEach(
|
||||
Array.from(runtime),
|
||||
([name, entry]) => {
|
||||
if (!entry.client) return Effect.succeed({ resources: [], templates: [] })
|
||||
return Effect.all(
|
||||
{
|
||||
resources: entry.client.resources().pipe(Effect.catch(() => Effect.succeed([]))),
|
||||
templates: entry.client.resourceTemplates().pipe(Effect.catch(() => Effect.succeed([]))),
|
||||
},
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(
|
||||
Effect.map((catalog) => ({
|
||||
resources: catalog.resources.map((def) => toResource(name, def)),
|
||||
templates: catalog.templates.map((def) => toResourceTemplate(name, def)),
|
||||
})),
|
||||
)
|
||||
},
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return ResourceCatalog.make({
|
||||
resources: catalogs
|
||||
.flatMap((catalog) => catalog.resources)
|
||||
.toSorted(
|
||||
(a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri),
|
||||
),
|
||||
templates: catalogs
|
||||
.flatMap((catalog) => catalog.templates)
|
||||
.toSorted(
|
||||
(a, b) =>
|
||||
a.server.localeCompare(b.server) ||
|
||||
a.name.localeCompare(b.name) ||
|
||||
a.uriTemplate.localeCompare(b.uriTemplate),
|
||||
),
|
||||
})
|
||||
}),
|
||||
readResource: Effect.fn("MCP.readResource")(function* (input) {
|
||||
yield* gate(input.server)
|
||||
return undefined
|
||||
const target = yield* requireServer(input.server)
|
||||
yield* Deferred.await(target.entry.startup)
|
||||
if (!target.entry.client) return undefined
|
||||
const result = yield* target.entry.client
|
||||
.readResource({ uri: input.uri })
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!result) return undefined
|
||||
return ResourceContent.make({
|
||||
server: target.name,
|
||||
uri: input.uri,
|
||||
contents: result.contents,
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import path from "path"
|
|||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Global } from "./global"
|
||||
import { Flag } from "./flag/flag"
|
||||
import { Flock } from "./util/flock"
|
||||
|
|
@ -18,10 +19,10 @@ export type CatalogModelStatus = typeof CatalogModelStatus.Type
|
|||
const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
|
||||
|
||||
const CostTier = Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
input: Money.USDPerMillionTokens,
|
||||
output: Money.USDPerMillionTokens,
|
||||
cache_read: Schema.optional(Money.USDPerMillionTokens),
|
||||
cache_write: Schema.optional(Money.USDPerMillionTokens),
|
||||
tier: Schema.Struct({
|
||||
type: Schema.Literal("context"),
|
||||
size: Schema.Finite,
|
||||
|
|
@ -29,17 +30,17 @@ const CostTier = Schema.Struct({
|
|||
})
|
||||
|
||||
const Cost = Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
input: Money.USDPerMillionTokens,
|
||||
output: Money.USDPerMillionTokens,
|
||||
cache_read: Schema.optional(Money.USDPerMillionTokens),
|
||||
cache_write: Schema.optional(Money.USDPerMillionTokens),
|
||||
tiers: Schema.optional(Schema.Array(CostTier)),
|
||||
context_over_200k: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
input: Money.USDPerMillionTokens,
|
||||
output: Money.USDPerMillionTokens,
|
||||
cache_read: Schema.optional(Money.USDPerMillionTokens),
|
||||
cache_write: Schema.optional(Money.USDPerMillionTokens),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as PluginV2 from "./plugin"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Event, ID, type Info } from "@opencode-ai/schema/plugin"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
|
||||
|
|
@ -18,6 +18,7 @@ import { SkillV2 } from "./skill"
|
|||
import { State } from "./state"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { ToolHooks } from "./tool/hooks"
|
||||
import { PluginHooks } from "./plugin/hooks"
|
||||
|
||||
export interface Interface {
|
||||
readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect<void>
|
||||
|
|
@ -57,12 +58,13 @@ const layer = Layer.effect(
|
|||
generation.length === definitions.length &&
|
||||
generation.every(
|
||||
(plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version,
|
||||
)
|
||||
) &&
|
||||
definitions.every((definition) => active.has(definition.id))
|
||||
) {
|
||||
return
|
||||
}
|
||||
generation = undefined
|
||||
const exit = yield* State.batch(
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
const scopes = Array.from(active.values()).toReversed()
|
||||
active.clear()
|
||||
|
|
@ -81,13 +83,17 @@ const layer = Layer.effect(
|
|||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isFailure(loaded)) return loaded
|
||||
if (Exit.isFailure(loaded)) {
|
||||
yield* Effect.logWarning("failed to load plugin", {
|
||||
"plugin.id": definition.id,
|
||||
cause: loaded.cause,
|
||||
})
|
||||
continue
|
||||
}
|
||||
active.set(definition.id, child)
|
||||
}
|
||||
return Exit.void
|
||||
}),
|
||||
)
|
||||
if (Exit.isFailure(exit)) return yield* exit
|
||||
generation = definitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
...(definition.version === undefined ? {} : { version: definition.version }),
|
||||
|
|
@ -131,6 +137,7 @@ export const node = makeLocationNode({
|
|||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
ToolHooks.node,
|
||||
PluginHooks.node,
|
||||
PluginRuntime.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ export const Plugin = define({
|
|||
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
draft.update(AgentV2.defaultID, (item) => {
|
||||
item.name = AgentV2.Name.make("Build")
|
||||
item.description = "The default agent. Executes tools based on configured permissions."
|
||||
item.mode = "primary"
|
||||
item.permissions.push(
|
||||
|
|
@ -136,6 +137,7 @@ export const Plugin = define({
|
|||
})
|
||||
|
||||
draft.update(AgentV2.ID.make("plan"), (item) => {
|
||||
item.name = AgentV2.Name.make("Plan")
|
||||
item.description = "Plan mode. Disallows all edit tools."
|
||||
item.mode = "primary"
|
||||
item.permissions.push(
|
||||
|
|
@ -155,6 +157,7 @@ export const Plugin = define({
|
|||
})
|
||||
|
||||
draft.update(AgentV2.ID.make("general"), (item) => {
|
||||
item.name = AgentV2.Name.make("General")
|
||||
item.description =
|
||||
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
|
||||
item.mode = "subagent"
|
||||
|
|
@ -167,6 +170,7 @@ export const Plugin = define({
|
|||
})
|
||||
|
||||
draft.update(AgentV2.ID.make("explore"), (item) => {
|
||||
item.name = AgentV2.Name.make("Explore")
|
||||
item.description =
|
||||
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
|
||||
item.system = PROMPT_EXPLORE
|
||||
|
|
@ -189,6 +193,7 @@ export const Plugin = define({
|
|||
})
|
||||
|
||||
draft.update(AgentV2.ID.make("compaction"), (item) => {
|
||||
item.name = AgentV2.Name.make("Compaction")
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_COMPACTION
|
||||
|
|
@ -196,6 +201,7 @@ export const Plugin = define({
|
|||
})
|
||||
|
||||
draft.update(AgentV2.ID.make("title"), (item) => {
|
||||
item.name = AgentV2.Name.make("Title")
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_TITLE
|
||||
|
|
@ -203,6 +209,7 @@ export const Plugin = define({
|
|||
})
|
||||
|
||||
draft.update(AgentV2.ID.make("summary"), (item) => {
|
||||
item.name = AgentV2.Name.make("Summary")
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
item.system = PROMPT_SUMMARY
|
||||
|
|
|
|||
67
packages/core/src/plugin/hooks.ts
Normal file
67
packages/core/src/plugin/hooks.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
export * as PluginHooks from "./hooks"
|
||||
|
||||
import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
|
||||
import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { State } from "../state"
|
||||
|
||||
export interface Domains {
|
||||
readonly aisdk: AISDKHooks
|
||||
readonly session: SessionHooks
|
||||
readonly tool: ToolHooks
|
||||
}
|
||||
|
||||
type Callback<Event> = (event: Event) => Effect.Effect<void>
|
||||
|
||||
export interface Interface {
|
||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
callback: Callback<Domains[Domain][Name]>,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
event: Domains[Domain][Name],
|
||||
) => Effect.Effect<Domains[Domain][Name]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginHooks") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const callbacks = new Map<string, Function[]>()
|
||||
const key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}`
|
||||
|
||||
const register: Interface["register"] = Effect.fn("PluginHooks.register")(function* (domain, name, callback) {
|
||||
const scope = yield* Scope.Scope
|
||||
const id = key(domain, name)
|
||||
let active = true
|
||||
callbacks.set(id, [...(callbacks.get(id) ?? []), callback])
|
||||
const dispose = Effect.sync(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
const next = (callbacks.get(id) ?? []).filter((item) => item !== callback)
|
||||
if (next.length === 0) callbacks.delete(id)
|
||||
else callbacks.set(id, next)
|
||||
})
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
return { dispose }
|
||||
})
|
||||
|
||||
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
|
||||
for (const callback of callbacks.get(key(domain, name)) ?? []) {
|
||||
const result: Effect.Effect<void> = Reflect.apply(callback, undefined, [event])
|
||||
yield* result
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return Service.of({ register, trigger })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
export * as PluginHost from "./host"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
|
|
@ -22,6 +22,7 @@ import { Tool } from "../tool/tool"
|
|||
import { Tools } from "../tool/tools"
|
||||
import { ToolHooks } from "../tool/hooks"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { PluginHooks } from "./hooks"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
|
||||
|
|
@ -36,6 +37,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const locationInfo = () =>
|
||||
new Location.Info({
|
||||
|
|
@ -43,7 +45,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
})
|
||||
const locationRef = (input?: Parameters<PluginContext["agent"]["list"]>[0]) =>
|
||||
const locationRef = (input?: Parameters<Plugin.Context["agent"]["list"]>[0]) =>
|
||||
input?.location === undefined
|
||||
? undefined
|
||||
: Location.Ref.make({
|
||||
|
|
@ -79,32 +81,32 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
}),
|
||||
},
|
||||
aisdk: {
|
||||
sdk: (callback) =>
|
||||
aisdk.hook.sdk((event) => {
|
||||
hook: (name, callback) => {
|
||||
if (name === "sdk") {
|
||||
return aisdk.hook.sdk((event) => {
|
||||
const output = {
|
||||
model: mutable(event.model),
|
||||
package: event.package,
|
||||
options: event.options,
|
||||
sdk: event.sdk,
|
||||
}
|
||||
return Reflect.apply(callback, undefined, [output]).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
|
||||
)
|
||||
})
|
||||
}
|
||||
return aisdk.hook.language((event) => {
|
||||
const output = {
|
||||
model: mutable(event.model),
|
||||
package: event.package,
|
||||
options: event.options,
|
||||
sdk: event.sdk,
|
||||
}
|
||||
const result = callback(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
|
||||
)
|
||||
}),
|
||||
language: (callback) =>
|
||||
aisdk.hook.language((event) => {
|
||||
const output = {
|
||||
model: mutable(event.model),
|
||||
sdk: event.sdk,
|
||||
options: event.options,
|
||||
language: event.language,
|
||||
}
|
||||
const result = callback(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
return Reflect.apply(callback, undefined, [output]).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (event.language = output.language))),
|
||||
)
|
||||
}),
|
||||
})
|
||||
},
|
||||
},
|
||||
catalog: {
|
||||
provider: {
|
||||
|
|
@ -164,25 +166,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
integration: {
|
||||
list: () => response(integration.list()),
|
||||
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
|
||||
connectKey: (input) =>
|
||||
integration.connection.key({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
key: input.key,
|
||||
label: input.label,
|
||||
}),
|
||||
connectOauth: (input) =>
|
||||
response(
|
||||
integration.connection.oauth({
|
||||
connect: {
|
||||
key: (input) =>
|
||||
integration.connection.key({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
methodID: Integration.MethodID.make(input.methodID),
|
||||
inputs: input.inputs,
|
||||
key: input.key,
|
||||
label: input.label,
|
||||
}),
|
||||
),
|
||||
attemptStatus: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))),
|
||||
attemptComplete: (input) =>
|
||||
integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }),
|
||||
attemptCancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)),
|
||||
oauth: (input) =>
|
||||
response(
|
||||
integration.connection.oauth({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
methodID: Integration.MethodID.make(input.methodID),
|
||||
inputs: input.inputs,
|
||||
label: input.label,
|
||||
}),
|
||||
),
|
||||
},
|
||||
attempt: {
|
||||
status: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))),
|
||||
complete: (input) =>
|
||||
integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }),
|
||||
cancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)),
|
||||
},
|
||||
reload: integration.reload,
|
||||
connection: {
|
||||
active: (id) => integration.connection.active(Integration.ID.make(id)),
|
||||
|
|
@ -317,11 +323,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
registrations,
|
||||
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
|
||||
{ discard: true },
|
||||
)
|
||||
).pipe(Effect.orDie)
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
execute: {
|
||||
before: (callback) =>
|
||||
toolHooks.hook.before((event) => {
|
||||
hook: (name, callback) => {
|
||||
if (name === "execute.before") {
|
||||
return toolHooks.hook.before((event) => {
|
||||
const output = {
|
||||
tool: event.tool,
|
||||
sessionID: event.sessionID,
|
||||
|
|
@ -330,38 +337,37 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
toolCallID: event.toolCallID,
|
||||
input: event.input,
|
||||
}
|
||||
const result = callback(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
return Reflect.apply(callback, undefined, [output]).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (event.input = output.input))),
|
||||
)
|
||||
}),
|
||||
after: (callback) =>
|
||||
toolHooks.hook.after((event) => {
|
||||
const output = {
|
||||
tool: event.tool,
|
||||
sessionID: event.sessionID,
|
||||
agent: event.agent,
|
||||
assistantMessageID: event.assistantMessageID,
|
||||
toolCallID: event.toolCallID,
|
||||
input: event.input,
|
||||
result: event.result,
|
||||
output: event.output,
|
||||
outputPaths: event.outputPaths,
|
||||
}
|
||||
const result = callback(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => {
|
||||
event.result = output.result
|
||||
event.output = output.output
|
||||
event.outputPaths = output.outputPaths
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}
|
||||
return toolHooks.hook.after((event) => {
|
||||
const output = {
|
||||
tool: event.tool,
|
||||
sessionID: event.sessionID,
|
||||
agent: event.agent,
|
||||
assistantMessageID: event.assistantMessageID,
|
||||
toolCallID: event.toolCallID,
|
||||
input: event.input,
|
||||
result: event.result,
|
||||
output: event.output,
|
||||
outputPaths: event.outputPaths,
|
||||
}
|
||||
return Reflect.apply(callback, undefined, [output]).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => {
|
||||
event.result = output.result
|
||||
event.output = output.output
|
||||
event.outputPaths = output.outputPaths
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
},
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) => hooks.register("session", name, callback),
|
||||
create: (input) =>
|
||||
runtime.session.create({
|
||||
id: input?.id,
|
||||
|
|
@ -375,5 +381,5 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
command: runtime.session.command,
|
||||
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
||||
},
|
||||
} satisfies PluginContext
|
||||
} satisfies Plugin.Context
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as PluginInternal from "./internal"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Context, Effect, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { AgentV2 } from "../agent"
|
||||
|
|
@ -31,7 +31,7 @@ import { SessionInstructions } from "../session/instructions"
|
|||
import { SessionTodo } from "../session/todo"
|
||||
import { Shell } from "../shell"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { ApplyPatchTool } from "../tool/apply-patch"
|
||||
import { PatchTool } from "../tool/patch"
|
||||
import { EditTool } from "../tool/edit"
|
||||
import { GlobTool } from "../tool/glob"
|
||||
import { GrepTool } from "../tool/grep"
|
||||
|
|
@ -127,7 +127,7 @@ const pre = [
|
|||
SkillPlugin.Plugin,
|
||||
ModelsDevPlugin,
|
||||
...ProviderPlugins,
|
||||
ApplyPatchTool.Plugin,
|
||||
PatchTool.Plugin,
|
||||
EditTool.Plugin,
|
||||
GlobTool.Plugin,
|
||||
GrepTool.Plugin,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import type { ModelInfo } from "@opencode-ai/sdk/v2/types"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
|
|
@ -11,13 +12,13 @@ function released(date: string) {
|
|||
return Number.isFinite(time) ? time : 0
|
||||
}
|
||||
|
||||
function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
|
||||
function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] {
|
||||
const base = {
|
||||
input: input?.input ?? 0,
|
||||
output: input?.output ?? 0,
|
||||
input: input?.input ?? Money.USDPerMillionTokens.zero,
|
||||
output: input?.output ?? Money.USDPerMillionTokens.zero,
|
||||
cache: {
|
||||
read: input?.cache_read ?? 0,
|
||||
write: input?.cache_write ?? 0,
|
||||
read: input?.cache_read ?? Money.USDPerMillionTokens.zero,
|
||||
write: input?.cache_write ?? Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
}
|
||||
return [
|
||||
|
|
@ -27,8 +28,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
|
|||
input: item.input,
|
||||
output: item.output,
|
||||
cache: {
|
||||
read: item.cache_read ?? 0,
|
||||
write: item.cache_write ?? 0,
|
||||
read: item.cache_read ?? Money.USDPerMillionTokens.zero,
|
||||
write: item.cache_write ?? Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
})) ?? []),
|
||||
...(input?.context_over_200k
|
||||
|
|
@ -41,8 +42,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
|
|||
input: input.context_over_200k.input,
|
||||
output: input.context_over_200k.output,
|
||||
cache: {
|
||||
read: input.context_over_200k.cache_read ?? 0,
|
||||
write: input.context_over_200k.cache_write ?? 0,
|
||||
read: input.context_over_200k.cache_read ?? Money.USDPerMillionTokens.zero,
|
||||
write: input.context_over_200k.cache_write ?? Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
@ -50,13 +51,13 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
|
|||
]
|
||||
}
|
||||
|
||||
function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] | undefined) {
|
||||
function mergeCost(base: ModelInfo["cost"], override: ModelsDev.Model["cost"] | undefined) {
|
||||
if (!override) return base
|
||||
const next = cost(override)
|
||||
const [baseDefault, ...baseTiers] = base
|
||||
const [nextDefault, ...nextTiers] = next
|
||||
const tierKey = (item: ModelV2Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}`
|
||||
const merge = (left: ModelV2Info["cost"][number], right: ModelV2Info["cost"][number]) => ({
|
||||
const tierKey = (item: ModelInfo["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}`
|
||||
const merge = (left: ModelInfo["cost"][number], right: ModelInfo["cost"][number]) => ({
|
||||
...left,
|
||||
...right,
|
||||
tier: right.tier ?? left.tier,
|
||||
|
|
@ -67,12 +68,25 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"]
|
|||
const current = tiers.get(tierKey(item))
|
||||
tiers.set(tierKey(item), current ? merge(current, item) : item)
|
||||
}
|
||||
return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()]
|
||||
return [
|
||||
merge(
|
||||
baseDefault ?? {
|
||||
input: Money.USDPerMillionTokens.zero,
|
||||
output: Money.USDPerMillionTokens.zero,
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.zero,
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
nextDefault,
|
||||
),
|
||||
...tiers.values(),
|
||||
]
|
||||
}
|
||||
|
||||
const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
|
||||
|
||||
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable<ModelV2Info["variants"]> {
|
||||
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable<ModelInfo["variants"]> {
|
||||
const npm = model.provider?.npm ?? provider.npm
|
||||
const options = model.reasoning_options ?? []
|
||||
const effort = options.find((option) => option.type === "effort")
|
||||
|
|
@ -117,7 +131,7 @@ function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.
|
|||
function budgetVariants(
|
||||
npm: string | undefined,
|
||||
option: Extract<NonNullable<ModelsDev.Model["reasoning_options"]>[number], { type: "budget_tokens" }>,
|
||||
): NonNullable<ModelV2Info["variants"]> {
|
||||
): NonNullable<ModelInfo["variants"]> {
|
||||
const max = option.max
|
||||
const high =
|
||||
option.max === undefined
|
||||
|
|
@ -146,7 +160,7 @@ function modeName(model: ModelsDev.Model, mode: string) {
|
|||
return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}`
|
||||
}
|
||||
|
||||
function mergeVariants(model: ModelV2Info, next: NonNullable<ModelV2Info["variants"]>) {
|
||||
function mergeVariants(model: ModelInfo, next: NonNullable<ModelInfo["variants"]>) {
|
||||
const variants = model.variants ?? []
|
||||
const existing = new Map(variants.map((variant) => [variant.id, variant]))
|
||||
const nextIDs = new Set(next.map((variant) => variant.id))
|
||||
|
|
@ -157,13 +171,13 @@ function mergeVariants(model: ModelV2Info, next: NonNullable<ModelV2Info["varian
|
|||
}
|
||||
|
||||
function applyModel(
|
||||
draft: ModelV2Info,
|
||||
draft: ModelInfo,
|
||||
model: ModelsDev.Model,
|
||||
input: {
|
||||
readonly name?: string
|
||||
readonly cost?: ModelV2Info["cost"]
|
||||
readonly cost?: ModelInfo["cost"]
|
||||
readonly request?: NonNullable<NonNullable<ModelsDev.Model["experimental"]>["modes"]>[string]["provider"]
|
||||
readonly variants?: NonNullable<ModelV2Info["variants"]>
|
||||
readonly variants?: NonNullable<ModelInfo["variants"]>
|
||||
} = {},
|
||||
) {
|
||||
draft.name = input.name ?? model.name
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
export * as PluginPromise from "./promise"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Scope, Stream } from "effect"
|
||||
|
||||
type HostRegistration = { readonly dispose: Effect.Effect<void> }
|
||||
type Registration = { readonly dispose: () => Promise<void> }
|
||||
type PromisePlugin = import("@opencode-ai/plugin/v2/plugin").Plugin
|
||||
type PromisePluginContext = import("@opencode-ai/plugin/v2/plugin").Context
|
||||
|
||||
/**
|
||||
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
|
||||
|
|
@ -16,8 +17,8 @@ type Registration = { readonly dispose: () => Promise<void> }
|
|||
* preserves boot-time batching, so Promise-plugin transforms still coalesce
|
||||
* into one reload per domain.
|
||||
*/
|
||||
export function fromPromise(plugin: Plugin) {
|
||||
return define({
|
||||
export function fromPromise(plugin: PromisePlugin) {
|
||||
return Plugin.define({
|
||||
id: plugin.id,
|
||||
effect: (host) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -43,7 +44,7 @@ export function fromPromise(plugin: Plugin) {
|
|||
}),
|
||||
)
|
||||
|
||||
const context2: PluginContext = {
|
||||
const context2: PromisePluginContext = {
|
||||
options: host.options,
|
||||
agent: {
|
||||
list: (input) => run(host.agent.list(input)),
|
||||
|
|
@ -51,10 +52,8 @@ export function fromPromise(plugin: Plugin) {
|
|||
reload: () => run(host.agent.reload()),
|
||||
},
|
||||
aisdk: {
|
||||
sdk: (callback) =>
|
||||
register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
language: (callback) =>
|
||||
register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
hook: (name, callback) =>
|
||||
register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
catalog: {
|
||||
provider: {
|
||||
|
|
@ -79,11 +78,15 @@ export function fromPromise(plugin: Plugin) {
|
|||
integration: {
|
||||
list: (input) => run(host.integration.list(input)),
|
||||
get: (input) => run(host.integration.get(input)),
|
||||
connectKey: (input) => run(host.integration.connectKey(input)),
|
||||
connectOauth: (input) => run(host.integration.connectOauth(input)),
|
||||
attemptStatus: (input) => run(host.integration.attemptStatus(input)),
|
||||
attemptComplete: (input) => run(host.integration.attemptComplete(input)),
|
||||
attemptCancel: (input) => run(host.integration.attemptCancel(input)),
|
||||
connect: {
|
||||
key: (input) => run(host.integration.connect.key(input)),
|
||||
oauth: (input) => run(host.integration.connect.oauth(input)),
|
||||
},
|
||||
attempt: {
|
||||
status: (input) => run(host.integration.attempt.status(input)),
|
||||
complete: (input) => run(host.integration.attempt.complete(input)),
|
||||
cancel: (input) => run(host.integration.attempt.cancel(input)),
|
||||
},
|
||||
transform: transform(host.integration),
|
||||
reload: () => run(host.integration.reload()),
|
||||
connection: {
|
||||
|
|
@ -104,12 +107,19 @@ export function fromPromise(plugin: Plugin) {
|
|||
transform: transform(host.skill),
|
||||
reload: () => run(host.skill.reload()),
|
||||
},
|
||||
tool: {
|
||||
transform: transform(host.tool),
|
||||
hook: (name, callback) =>
|
||||
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
session: {
|
||||
create: (input) => run(host.session.create(input)),
|
||||
get: (input) => run(host.session.get(input)),
|
||||
prompt: (input) => run(host.session.prompt(input)),
|
||||
command: (input) => run(host.session.command(input)),
|
||||
interrupt: (input) => run(host.session.interrupt(input)),
|
||||
hook: (name, callback) =>
|
||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||
export const AlibabaPlugin = define({
|
||||
id: "opencode.provider.alibaba",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/alibaba") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ export const AmazonBedrockPlugin = define({
|
|||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
|
||||
const options = { ...evt.options }
|
||||
|
|
@ -108,7 +109,8 @@ export const AmazonBedrockPlugin = define({
|
|||
evt.sdk = mod.createAmazonBedrock(options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ export const AnthropicPlugin = define({
|
|||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/anthropic") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ export const AzurePlugin = define({
|
|||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/azure") return
|
||||
if (evt.model.providerID === ProviderV2.ID.azure) {
|
||||
|
|
@ -44,7 +45,8 @@ export const AzurePlugin = define({
|
|||
evt.sdk = mod.createAzure(evt.options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.azure) return
|
||||
evt.language = selectLanguage(
|
||||
|
|
@ -75,7 +77,8 @@ export const AzureCognitiveServicesPlugin = define({
|
|||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.language(
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
|
||||
evt.language = selectLanguage(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ export const CerebrasPlugin = define({
|
|||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cerebras") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras"))
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||
export const CloudflareAIGatewayPlugin = define({
|
||||
id: "opencode.provider.cloudflare-ai-gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "ai-gateway-provider") return
|
||||
if (evt.options.baseURL) return
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ export const CloudflareWorkersAIPlugin = define({
|
|||
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
|
||||
})
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
if (evt.package !== "@ai-sdk/openai-compatible") return
|
||||
|
|
@ -35,7 +36,8 @@ export const CloudflareWorkersAIPlugin = define({
|
|||
)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||
export const CoherePlugin = define({
|
||||
id: "opencode.provider.cohere",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cohere") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||
export const DeepInfraPlugin = define({
|
||||
id: "opencode.provider.deepinfra",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/deepinfra") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ export const DynamicProviderPlugin = define({
|
|||
id: "opencode.provider.dynamic",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const npm = yield* Npm.Service
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.sdk) return
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||
export const GatewayPlugin = define({
|
||||
id: "opencode.provider.gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/gateway") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))
|
||||
|
|
|
|||
|
|
@ -23,14 +23,16 @@ export const GithubCopilotPlugin = define({
|
|||
model.enabled = false
|
||||
})
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/github-copilot") return
|
||||
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
|
||||
evt.sdk = mod.createOpenaiCompatible(evt.options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
|
||||
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import { ProviderV2 } from "../../provider"
|
|||
export const GitLabPlugin = define({
|
||||
id: "opencode.provider.gitlab",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "gitlab-ai-provider") return
|
||||
const mod = yield* Effect.promise(() => import("gitlab-ai-provider"))
|
||||
|
|
@ -31,7 +32,8 @@ export const GitLabPlugin = define({
|
|||
})
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.gitlab) return
|
||||
const featureFlags =
|
||||
|
|
|
|||
|
|
@ -85,7 +85,8 @@ export const GoogleVertexPlugin = define({
|
|||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
|
||||
evt.options.fetch = authFetch(evt.options.fetch)
|
||||
|
|
@ -104,7 +105,8 @@ export const GoogleVertexPlugin = define({
|
|||
})
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.googleVertex) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim())
|
||||
|
|
@ -135,7 +137,8 @@ export const GoogleVertexAnthropicPlugin = define({
|
|||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
|
||||
|
|
@ -161,7 +164,8 @@ export const GoogleVertexAnthropicPlugin = define({
|
|||
})
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim())
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||
export const GooglePlugin = define({
|
||||
id: "opencode.provider.google",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/google") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google"))
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||
export const GroqPlugin = define({
|
||||
id: "opencode.provider.groq",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/groq") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||
export const MistralPlugin = define({
|
||||
id: "opencode.provider.mistral",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/mistral") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||
export const OpenAICompatiblePlugin = define({
|
||||
id: "opencode.provider.openai-compatible",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.sdk) return
|
||||
if (!evt.package.includes("@ai-sdk/openai-compatible")) return
|
||||
|
|
|
|||
|
|
@ -210,14 +210,16 @@ export const OpenAIPlugin = define({
|
|||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh().pipe(Effect.forkScoped)
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/openai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
|
||||
evt.sdk = mod.createOpenAI(evt.options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.openai) return
|
||||
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Integration } from "../../integration"
|
|||
import { ModelV2 } from "../../model"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { ConfigProviderV1 } from "../../v1/config/provider"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options"
|
||||
import { ConfigV1 } from "../../v1/config/config"
|
||||
|
||||
|
|
@ -220,20 +221,23 @@ function withoutCredentials(body: Readonly<Record<string, unknown>> | undefined)
|
|||
|
||||
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
|
||||
const base = {
|
||||
input: input.input,
|
||||
output: input.output,
|
||||
cache: { read: input.cache_read ?? 0, write: input.cache_write ?? 0 },
|
||||
input: Money.USDPerMillionTokens.make(input.input),
|
||||
output: Money.USDPerMillionTokens.make(input.output),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make(input.cache_read ?? 0),
|
||||
write: Money.USDPerMillionTokens.make(input.cache_write ?? 0),
|
||||
},
|
||||
}
|
||||
if (!input.context_over_200k) return [base]
|
||||
return [
|
||||
base,
|
||||
{
|
||||
tier: { type: "context" as const, size: 200_000 },
|
||||
input: input.context_over_200k.input,
|
||||
output: input.context_over_200k.output,
|
||||
input: Money.USDPerMillionTokens.make(input.context_over_200k.input),
|
||||
output: Money.USDPerMillionTokens.make(input.context_over_200k.output),
|
||||
cache: {
|
||||
read: input.context_over_200k.cache_read ?? 0,
|
||||
write: input.context_over_200k.cache_write ?? 0,
|
||||
read: Money.USDPerMillionTokens.make(input.context_over_200k.cache_read ?? 0),
|
||||
write: Money.USDPerMillionTokens.make(input.context_over_200k.cache_write ?? 0),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ export const OpenRouterPlugin = define({
|
|||
}
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@openrouter/ai-sdk-provider") return
|
||||
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||
export const PerplexityPlugin = define({
|
||||
id: "opencode.provider.perplexity",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/perplexity") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ export const SapAICorePlugin = define({
|
|||
id: "opencode.provider.sap-ai-core",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const npm = yield* Npm.Service
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
|
||||
const serviceKey =
|
||||
|
|
@ -37,7 +38,8 @@ export const SapAICorePlugin = define({
|
|||
)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
|
||||
evt.language = evt.sdk(evt.model.modelID ?? evt.model.id)
|
||||
|
|
|
|||
|
|
@ -67,7 +67,8 @@ export function cortexFetch(upstream: FetchLike = fetch) {
|
|||
export const SnowflakeCortexPlugin = define({
|
||||
id: "opencode.provider.snowflake-cortex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
|
||||
const token =
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||
export const TogetherAIPlugin = define({
|
||||
id: "opencode.provider.togetherai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/togetherai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
|||
export const VenicePlugin = define({
|
||||
id: "opencode.provider.venice",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "venice-ai-sdk-provider") return
|
||||
const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ export const VercelPlugin = define({
|
|||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/vercel") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/vercel"))
|
||||
|
|
|
|||
|
|
@ -5,14 +5,16 @@ import { ProviderV2 } from "../../provider"
|
|||
export const XAIPlugin = define({
|
||||
id: "opencode.provider.xai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.sdk(
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/xai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
|
||||
evt.sdk = mod.createXai(evt.options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("xai")) return
|
||||
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
|
||||
|
|
|
|||
|
|
@ -1,20 +1,12 @@
|
|||
export * as SdkPlugins from "./sdk"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
|
||||
export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {} })
|
||||
|
||||
export interface Store {
|
||||
readonly plugins: Map<string, Plugin>
|
||||
}
|
||||
|
||||
export const makeStore = (): Store => ({ plugins: new Map() })
|
||||
|
||||
const defaultStore = makeStore()
|
||||
|
||||
/**
|
||||
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
|
||||
* so `PluginSupervisor` can add them on every Location boot through the ordinary
|
||||
|
|
@ -22,10 +14,9 @@ const defaultStore = makeStore()
|
|||
* config. Registration publishes an unlocated update so every booted Location
|
||||
* reloads its plugin generation from the shared store.
|
||||
*
|
||||
* The store is shared explicitly between the SDK construction graph and the
|
||||
* embedded route graph because `LocationServiceMap` builds Location layers lazily
|
||||
* in a nested graph. Each embedded SDK creates its own store, so instances do not
|
||||
* see each other's contributions.
|
||||
* Each host-global layer owns one private store. Location graphs reuse that
|
||||
* layer through Effect's memoization, so separate hosts remain isolated while
|
||||
* every Location in one host sees the same registrations.
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly register: (plugin: Plugin) => Effect.Effect<void>
|
||||
|
|
@ -34,26 +25,19 @@ export interface Interface {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
|
||||
|
||||
export const layerWithStore = (store: Store) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
yield* Effect.addFinalizer(() =>
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const plugins = new Map<string, Plugin>()
|
||||
return Service.of({
|
||||
register: (plugin) =>
|
||||
Effect.sync(() => {
|
||||
store.plugins.clear()
|
||||
}),
|
||||
)
|
||||
return Service.of({
|
||||
register: (plugin) =>
|
||||
Effect.sync(() => {
|
||||
store.plugins.set(plugin.id, plugin)
|
||||
}).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid),
|
||||
all: () => [...store.plugins.values()],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = layerWithStore(defaultStore)
|
||||
plugins.set(plugin.id, plugin)
|
||||
}).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid),
|
||||
all: () => [...plugins.values()],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [EventV2.node] })
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ export const Plugin = define({
|
|||
SkillV2.EmbeddedSource.make({
|
||||
type: "embedded",
|
||||
skill: SkillV2.Info.make({
|
||||
name: "opencode",
|
||||
id: SkillV2.ID.make("opencode"),
|
||||
name: SkillV2.Name.make("OpenCode"),
|
||||
description: OpencodeDescription,
|
||||
location: AbsolutePath.make("/builtin/opencode.md"),
|
||||
content: OpencodeContent,
|
||||
|
|
@ -44,7 +45,8 @@ export const Plugin = define({
|
|||
SkillV2.EmbeddedSource.make({
|
||||
type: "embedded",
|
||||
skill: SkillV2.Info.make({
|
||||
name: "report",
|
||||
id: SkillV2.ID.make("report"),
|
||||
name: SkillV2.Name.make("Report"),
|
||||
description: REPORT_DESCRIPTION,
|
||||
slash: true,
|
||||
location: AbsolutePath.make("/builtin/report.md"),
|
||||
|
|
@ -103,15 +105,22 @@ const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* (
|
|||
})
|
||||
|
||||
function terminal() {
|
||||
return [
|
||||
process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined,
|
||||
process.env.TERM ? `TERM=${process.env.TERM}` : undefined,
|
||||
process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined,
|
||||
]
|
||||
.filter((item): item is string => item !== undefined)
|
||||
.join(", ") || "Unavailable: terminal environment variables are not set"
|
||||
return (
|
||||
[
|
||||
process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined,
|
||||
process.env.TERM ? `TERM=${process.env.TERM}` : undefined,
|
||||
process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined,
|
||||
]
|
||||
.filter((item): item is string => item !== undefined)
|
||||
.join(", ") || "Unavailable: terminal environment variables are not set"
|
||||
)
|
||||
}
|
||||
|
||||
function shell() {
|
||||
return process.env.SHELL ?? process.env.ComSpec ?? process.env.COMSPEC ?? "Unavailable: shell environment variable is not set"
|
||||
return (
|
||||
process.env.SHELL ??
|
||||
process.env.ComSpec ??
|
||||
process.env.COMSPEC ??
|
||||
"Unavailable: shell environment variable is not set"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as PluginSupervisor from "./supervisor"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import path from "path"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as SessionV2 from "./session"
|
||||
export * from "./session/schema"
|
||||
|
||||
import { Cause, DateTime, Effect, Layer, Schema, Context, Option, Stream, Scope } from "effect"
|
||||
import { Cause, Effect, Layer, Schema, Context, Option, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { ProjectV2 } from "./project"
|
||||
|
|
@ -19,6 +19,7 @@ import { SessionSchema } from "./session/schema"
|
|||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { SessionV1 } from "./v1/session"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { InstallationVersion } from "./installation/version"
|
||||
import { Slug } from "./util/slug"
|
||||
import { ProjectTable } from "./project/sql"
|
||||
|
|
@ -34,7 +35,7 @@ import { SessionEvent } from "./session/event"
|
|||
import { SessionInput } from "./session/input"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { SessionRevert } from "./session/revert"
|
||||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Mime } from "./mime"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
|
|
@ -46,8 +47,8 @@ import type { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
|||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
export const RevertState = Revert.State
|
||||
export type RevertState = Revert.State
|
||||
export const RevertState = Session.Revert
|
||||
export type RevertState = Session.Revert
|
||||
|
||||
// get project -> project.locations
|
||||
//
|
||||
|
|
@ -136,7 +137,7 @@ export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.Bus
|
|||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||
skill: Schema.String,
|
||||
skill: SkillV2.ID,
|
||||
}) {}
|
||||
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
|
|
@ -170,14 +171,14 @@ export interface Interface {
|
|||
id: SessionMessage.ID
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
}) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
readonly message: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
}) => Effect.Effect<SessionMessage.Message | undefined>
|
||||
}) => Effect.Effect<SessionMessage.Info | undefined>
|
||||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
/**
|
||||
* Durable, ordered, gap-free session log read. Replays public durable
|
||||
* session events after the exclusive `after` cursor, emits a `Synced`
|
||||
|
|
@ -191,7 +192,10 @@ export interface Interface {
|
|||
after?: number
|
||||
follow?: boolean
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchAgent: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
agent: AgentV2.ID
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
model: ModelV2.Ref
|
||||
|
|
@ -209,7 +213,7 @@ export interface Interface {
|
|||
sessionID: SessionSchema.ID
|
||||
command: string
|
||||
arguments?: string
|
||||
agent?: string
|
||||
agent?: AgentV2.ID
|
||||
model?: ModelV2.Ref
|
||||
files?: PromptInput.Prompt["files"]
|
||||
agents?: PromptInput.Prompt["agents"]
|
||||
|
|
@ -227,7 +231,7 @@ export interface Interface {
|
|||
readonly skill: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
skill: string
|
||||
skill: SkillV2.ID
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
|
||||
readonly compact: (
|
||||
|
|
@ -243,13 +247,14 @@ export interface Interface {
|
|||
text: string
|
||||
description?: string
|
||||
metadata?: Record<string, unknown>
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly revert: {
|
||||
readonly stage: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
files?: boolean
|
||||
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
|
||||
}) => Effect.Effect<Session.Revert, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
|
||||
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
|
||||
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
|
||||
}
|
||||
|
|
@ -272,7 +277,7 @@ const layer = Layer.effect(
|
|||
const scope = yield* Scope.Scope
|
||||
const activeShells = new Set<SessionSchema.ID>()
|
||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
|
|
@ -321,7 +326,7 @@ const layer = Layer.effect(
|
|||
variant: input.model.variant,
|
||||
}
|
||||
: undefined,
|
||||
cost: 0,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: now, updated: now },
|
||||
})
|
||||
|
|
@ -539,7 +544,7 @@ const layer = Layer.effect(
|
|||
})
|
||||
const model = command.model ?? commandAgent?.model ?? input.model
|
||||
if (agent !== undefined && session.agent !== AgentV2.ID.make(agent))
|
||||
yield* result.switchAgent({ sessionID: input.sessionID, agent })
|
||||
yield* result.switchAgent({ sessionID: input.sessionID, agent: AgentV2.ID.make(agent) })
|
||||
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
|
||||
|
||||
return yield* result.prompt({
|
||||
|
|
@ -601,12 +606,13 @@ const layer = Layer.effect(
|
|||
skill: Effect.fn("V2Session.skill")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const skill = (yield* skills.list()).find((item) => item.name === input.skill)
|
||||
const skill = (yield* skills.list()).find((item) => item.id === input.skill)
|
||||
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
|
||||
yield* events.publish(
|
||||
SessionEvent.Skill.Activated,
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
text: skill.content,
|
||||
},
|
||||
|
|
@ -693,6 +699,7 @@ const layer = Layer.effect(
|
|||
description: input.description,
|
||||
metadata: input.metadata,
|
||||
})
|
||||
if (input.resume === false) return
|
||||
yield* execution
|
||||
.resume(input.sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
|
|
|
|||
|
|
@ -25,20 +25,27 @@ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <te
|
|||
- [constraints/preferences, decisions and why, important facts/assumptions, exact context needed to continue, or "(none)"]
|
||||
|
||||
## Work State
|
||||
- Completed: [finished work, verified facts, or changes made; otherwise "(none)"]
|
||||
- Active: [current work, partial changes, or investigation state; otherwise "(none)"]
|
||||
- Blocked: [blockers, failing commands, or unknowns; otherwise "(none)"]
|
||||
### Completed
|
||||
- [finished work, verified facts, or changes made; otherwise "(none)"]
|
||||
|
||||
### Active
|
||||
- [current work, partial changes, or investigation state; otherwise "(none)"]
|
||||
|
||||
### Blocked
|
||||
- [blockers, failing commands, or unknowns; otherwise "(none)"]
|
||||
|
||||
## Next Move
|
||||
1. [immediate concrete action, or "(none)"]
|
||||
2. [next action if known, or "(none)"]
|
||||
|
||||
## Relevant Files
|
||||
- [file or directory path: why it matters, or "(none)"]
|
||||
</template>
|
||||
|
||||
Rules:
|
||||
- Keep every section, even when empty.
|
||||
- Use terse bullets, not prose paragraphs.
|
||||
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
|
||||
- Put relevant files and symbols inside the section where they matter; do not add extra sections.
|
||||
- Do not mention the summary process or that context was compacted.`
|
||||
|
||||
type Settings = {
|
||||
|
|
@ -57,19 +64,21 @@ type Dependencies = {
|
|||
|
||||
export type AutoInput = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messages: readonly SessionMessage.Message[]
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly request: LLMRequest
|
||||
}
|
||||
|
||||
type CompactInput = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messages: readonly SessionMessage.Message[]
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly model: Model
|
||||
readonly inputID?: SessionMessage.ID
|
||||
}
|
||||
|
||||
export type ManualInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Message[]
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
readonly inputID: SessionMessage.ID
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
|
|
@ -92,7 +101,7 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
|
|||
)
|
||||
.join("\n")
|
||||
|
||||
const serialize = (message: SessionMessage.Message) => {
|
||||
const serialize = (message: SessionMessage.Info) => {
|
||||
if (message.type === "user") {
|
||||
const files =
|
||||
message.files?.map(
|
||||
|
|
@ -121,7 +130,7 @@ const serialize = (message: SessionMessage.Message) => {
|
|||
if (message.type === "system") return `[System update]: ${message.text}`
|
||||
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
|
||||
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
|
||||
if (message.type === "shell") return `[Shell]: ${message.shell.command}\n${truncate(message.output?.output ?? "")}`
|
||||
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output?.output ?? "")}`
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +149,7 @@ const settings = (documents: readonly Config.Entry[]) => {
|
|||
}
|
||||
|
||||
const select = (
|
||||
messages: readonly SessionMessage.Message[],
|
||||
messages: readonly SessionMessage.Info[],
|
||||
tokens: number,
|
||||
): { readonly head: string; readonly recent: string } | undefined => {
|
||||
const conversation = messages
|
||||
|
|
@ -191,6 +200,7 @@ const make = (dependencies: Dependencies) => {
|
|||
readonly context: readonly string[]
|
||||
readonly recent: string
|
||||
readonly output?: number
|
||||
readonly inputID?: SessionMessage.ID
|
||||
}) {
|
||||
const context = input.model.route.defaults.limits?.context
|
||||
if (context === undefined || context <= 0) return false
|
||||
|
|
@ -201,6 +211,8 @@ const make = (dependencies: Dependencies) => {
|
|||
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: input.sessionID,
|
||||
reason: input.reason,
|
||||
recent: input.recent,
|
||||
inputID: input.inputID,
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
|
|
@ -228,9 +240,27 @@ const make = (dependencies: Dependencies) => {
|
|||
}),
|
||||
Effect.as(true),
|
||||
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
|
||||
Effect.onInterrupt(() =>
|
||||
input.reason === "auto"
|
||||
? dependencies.events.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
reason: input.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
const summary = chunks.join("")
|
||||
if (!summarized || failed || !summary.trim()) return false
|
||||
if (!summarized || failed || !summary.trim()) {
|
||||
yield* dependencies.events.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
reason: input.reason,
|
||||
error: { type: "compaction.failed", message: "Compaction produced no summary" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
return false
|
||||
}
|
||||
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
reason: input.reason,
|
||||
|
|
@ -277,6 +307,7 @@ const make = (dependencies: Dependencies) => {
|
|||
),
|
||||
recent: forcedShortContext ? "" : selected.recent,
|
||||
output: input.output,
|
||||
inputID: input.inputID,
|
||||
})
|
||||
})
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
|
||||
|
|
@ -320,6 +351,7 @@ export const layer = Layer.effect(
|
|||
sessionID: input.session.id,
|
||||
messages: input.messages,
|
||||
model: resolved.model,
|
||||
inputID: input.inputID,
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { InstructionCheckpointTable, SessionMessageTable } from "./sql"
|
|||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { DateTime } from "effect"
|
||||
import { DateTime, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Location } from "../location"
|
||||
import { ModelV2 } from "../model"
|
||||
|
|
@ -9,7 +9,10 @@ import { WorkspaceV2 } from "../workspace"
|
|||
import { SessionSchema } from "./schema"
|
||||
import { SessionTable } from "./sql"
|
||||
import { SessionMessage } from "./message"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { PersistedRevert } from "@opencode-ai/schema/session-revert"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
|
||||
const decodeRevert = Schema.decodeUnknownSync(PersistedRevert)
|
||||
|
||||
export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
|
||||
return SessionSchema.Info.make({
|
||||
|
|
@ -31,7 +34,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
|||
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
|
||||
}
|
||||
: undefined,
|
||||
cost: row.cost,
|
||||
cost: Money.USD.make(row.cost),
|
||||
tokens: {
|
||||
input: row.tokens_input,
|
||||
output: row.tokens_output,
|
||||
|
|
@ -46,7 +49,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
|||
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
|
||||
}),
|
||||
subpath: row.path ? RelativePath.make(row.path) : undefined,
|
||||
revert: row.revert ? { ...row.revert, messageID: SessionMessage.ID.make(row.revert.messageID) } : undefined,
|
||||
revert: row.revert ? decodeRevert(row.revert) : undefined,
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(row.time_created),
|
||||
updated: DateTime.makeUnsafe(row.time_updated),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export * as SessionInput from "./input"
|
|||
|
||||
import { and, asc, eq, isNull } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Admitted, Compaction, Delivery, Entry, PromptEntry } from "@opencode-ai/schema/session-input"
|
||||
import { Admitted, Compaction, Delivery, Info, PromptEntry } from "@opencode-ai/schema/session-input"
|
||||
import type { Database } from "../database/database"
|
||||
import type { EventV2 } from "../event"
|
||||
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||
|
|
@ -14,7 +14,7 @@ import { SessionInputTable, SessionMessageTable } from "./sql"
|
|||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
export { Admitted, Compaction, Delivery, Entry, PromptEntry }
|
||||
export { Admitted, Compaction, Delivery, Info, PromptEntry }
|
||||
|
||||
const decodePrompt = Schema.decodeUnknownSync(Prompt)
|
||||
const encodePrompt = Schema.encodeSync(Prompt)
|
||||
|
|
@ -24,7 +24,7 @@ export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict
|
|||
id: SessionMessage.ID,
|
||||
}) {}
|
||||
|
||||
const fromRow = (row: typeof SessionInputTable.$inferSelect): Entry => {
|
||||
const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => {
|
||||
const base = {
|
||||
admittedSeq: row.admitted_seq,
|
||||
id: SessionMessage.ID.make(row.id),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { SessionEvent } from "./event"
|
|||
import { SessionMessage } from "./message"
|
||||
|
||||
export type MemoryState = {
|
||||
messages: SessionMessage.Message[]
|
||||
messages: SessionMessage.Info[]
|
||||
}
|
||||
|
||||
export interface Adapter {
|
||||
|
|
@ -14,13 +14,13 @@ export interface Adapter {
|
|||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getShell: (
|
||||
shellID: SessionMessage.Shell["shell"]["id"],
|
||||
shellID: SessionMessage.Shell["shellID"],
|
||||
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
|
||||
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
|
||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
|
||||
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
|
||||
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void, never, never>
|
||||
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void, never, never>
|
||||
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
|
||||
}
|
||||
|
||||
export function memory(state: MemoryState): Adapter {
|
||||
|
|
@ -29,9 +29,7 @@ export function memory(state: MemoryState): Adapter {
|
|||
const shellIndex = (messageID: SessionMessage.ID) =>
|
||||
state.messages.findLastIndex((message) => message.id === messageID)
|
||||
const compactionIndex = () =>
|
||||
state.messages.findLastIndex(
|
||||
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
|
||||
)
|
||||
state.messages.findLastIndex((message) => message.type === "compaction" && message.status === "running")
|
||||
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
|
||||
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
|
||||
|
||||
|
|
@ -64,7 +62,7 @@ export function memory(state: MemoryState): Adapter {
|
|||
getShell(shellID) {
|
||||
return Effect.sync(() => {
|
||||
return state.messages.find((message): message is SessionMessage.Shell => {
|
||||
return message.type === "shell" && message.shell.id === shellID
|
||||
return message.type === "shell" && message.shellID === shellID
|
||||
})
|
||||
})
|
||||
},
|
||||
|
|
@ -143,6 +141,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
|
||||
return Effect.gen(function* () {
|
||||
yield* SessionEvent.All.match(event, {
|
||||
"session.usage.updated": () => Effect.void,
|
||||
"session.agent.selected": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.AgentSelected.make({
|
||||
|
|
@ -185,13 +184,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "system",
|
||||
text: event.data.text,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
),
|
||||
"session.synthetic": (event) => {
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.Synthetic.make({
|
||||
sessionID: event.data.sessionID,
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
|
|
@ -206,8 +205,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
SessionMessage.Skill.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "skill",
|
||||
skill: event.data.id,
|
||||
name: event.data.name,
|
||||
text: event.data.text,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
|
|
@ -218,7 +219,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "shell",
|
||||
metadata: event.metadata,
|
||||
shell: event.data.shell,
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
|
|
@ -229,7 +232,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
if (currentShell) {
|
||||
yield* adapter.updateShell(
|
||||
produce(currentShell, (draft) => {
|
||||
draft.shell = castDraft(event.data.shell)
|
||||
draft.status = event.data.shell.status
|
||||
draft.exit = event.data.shell.exit
|
||||
draft.output = event.data.output
|
||||
draft.time.completed = event.created
|
||||
}),
|
||||
|
|
@ -269,6 +273,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
type: "assistant",
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
content: [],
|
||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||
|
|
@ -296,6 +301,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
draft.finish = "error"
|
||||
draft.error = castDraft(event.data.error)
|
||||
draft.retry = undefined
|
||||
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
|
||||
draft.cost = event.data.cost
|
||||
draft.tokens = castDraft(event.data.tokens)
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.text.started": (event) => {
|
||||
|
|
@ -324,7 +333,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
time: { created: event.created },
|
||||
state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }),
|
||||
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -334,7 +343,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
"session.tool.input.ended": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && match.state.status === "pending") match.state.input = event.data.text
|
||||
if (match && match.state.status === "streaming") match.state.input = event.data.text
|
||||
})
|
||||
},
|
||||
"session.tool.called": (event) => {
|
||||
|
|
@ -377,7 +386,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
input: match.state.input,
|
||||
structured: event.data.structured,
|
||||
content: [...event.data.content],
|
||||
outputPaths: event.data.outputPaths ? [...event.data.outputPaths] : [],
|
||||
result: event.data.result,
|
||||
}),
|
||||
)
|
||||
|
|
@ -387,7 +395,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
"session.tool.failed": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestTool(draft, event.data.callID)
|
||||
if (match && (match.state.status === "pending" || match.state.status === "running")) {
|
||||
if (match && (match.state.status === "streaming" || match.state.status === "running")) {
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
|
|
@ -443,31 +451,30 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
}
|
||||
})
|
||||
},
|
||||
"session.compaction.admitted": (event) =>
|
||||
"session.compaction.admitted": () => Effect.void,
|
||||
"session.compaction.started": (event) =>
|
||||
adapter.appendMessage(
|
||||
SessionMessage.Compaction.make({
|
||||
id: event.data.inputID,
|
||||
SessionMessage.CompactionRunning.make({
|
||||
id: event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
|
||||
type: "compaction",
|
||||
status: "queued",
|
||||
status: "running",
|
||||
metadata: event.metadata,
|
||||
reason: "manual",
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: "",
|
||||
recent: event.data.recent ?? "",
|
||||
time: { created: event.created },
|
||||
}),
|
||||
),
|
||||
"session.compaction.started": (event) =>
|
||||
"session.compaction.delta": (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.data.reason !== "manual") return
|
||||
const current = yield* adapter.getCompaction()
|
||||
if (!current) return
|
||||
yield* adapter.updateCompaction({ ...current, status: "running" })
|
||||
if (current?.status !== "running") return
|
||||
yield* adapter.updateCompaction({ ...current, summary: current.summary + event.data.text })
|
||||
}),
|
||||
"session.compaction.delta": () => Effect.void,
|
||||
"session.compaction.ended": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const current = event.data.reason === "manual" ? yield* adapter.getCompaction() : undefined
|
||||
if (current) {
|
||||
const current = yield* adapter.getCompaction()
|
||||
if (current?.status === "running") {
|
||||
yield* adapter.updateCompaction({
|
||||
...current,
|
||||
status: "completed",
|
||||
|
|
@ -491,11 +498,20 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
)
|
||||
})
|
||||
},
|
||||
"session.compaction.failed": () =>
|
||||
"session.compaction.failed": (event) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* adapter.getCompaction()
|
||||
if (!current) return
|
||||
yield* adapter.updateCompaction({ ...current, status: "failed" })
|
||||
const failed = SessionMessage.CompactionFailed.make({
|
||||
id: current?.id ?? event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
metadata: current?.metadata ?? event.metadata,
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
time: current?.time ?? { created: event.created },
|
||||
})
|
||||
if (current?.status === "running") return yield* adapter.updateCompaction(failed)
|
||||
yield* adapter.appendMessage(failed)
|
||||
}),
|
||||
"session.revert.staged": () => Effect.void,
|
||||
"session.revert.cleared": () => Effect.void,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as SessionProjector from "./projector"
|
||||
|
||||
import { and, asc, desc, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
|
|
@ -24,15 +24,14 @@ import {
|
|||
} from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
import { Slug } from "../util/slug"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type MessageEvent = Exclude<
|
||||
SessionEvent.DurableEvent,
|
||||
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
|
||||
>
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
|
||||
export class SessionAlreadyProjected extends Error {}
|
||||
|
||||
|
|
@ -48,11 +47,6 @@ type Usage = {
|
|||
|
||||
const ForkBatchSize = 500
|
||||
|
||||
const emptyUsage = (): Usage => ({
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
|
||||
const forkTitle = (value: string) => {
|
||||
const match = value.match(/^(.+) \(fork #(\d+)\)$/)
|
||||
if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})`
|
||||
|
|
@ -67,22 +61,6 @@ function usage(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"] |
|
|||
return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] }
|
||||
}
|
||||
|
||||
function addUsage(target: Usage, value: Usage) {
|
||||
target.cost += value.cost
|
||||
target.tokens.input += value.tokens.input
|
||||
target.tokens.output += value.tokens.output
|
||||
target.tokens.reasoning += value.tokens.reasoning
|
||||
target.tokens.cache.read += value.tokens.cache.read
|
||||
target.tokens.cache.write += value.tokens.cache.write
|
||||
}
|
||||
|
||||
function messageUsage(row: typeof SessionMessageTable.$inferSelect): Usage | undefined {
|
||||
if (row.type !== "assistant") return undefined
|
||||
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
if (message.type !== "assistant" || message.cost === undefined || message.tokens === undefined) return undefined
|
||||
return { cost: message.cost, tokens: message.tokens }
|
||||
}
|
||||
|
||||
function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInsert {
|
||||
return {
|
||||
id: info.id,
|
||||
|
|
@ -108,7 +86,14 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse
|
|||
tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning,
|
||||
tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read,
|
||||
tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write,
|
||||
revert: info.revert ? { ...info.revert, messageID: SessionMessage.ID.make(info.revert.messageID) } : null,
|
||||
revert: info.revert
|
||||
? {
|
||||
messageID: SessionMessage.ID.make(info.revert.messageID),
|
||||
partID: info.revert.partID,
|
||||
snapshot: info.revert.snapshot,
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
: null,
|
||||
permission: info.permission ? [...info.permission] : undefined,
|
||||
time_created: info.time.created,
|
||||
time_updated: info.time.updated,
|
||||
|
|
@ -151,6 +136,37 @@ function applyUsage(
|
|||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
sessionID: (typeof SessionEvent.Step.Ended.Type)["data"]["sessionID"],
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({
|
||||
cost: SessionTable.cost,
|
||||
input: SessionTable.tokens_input,
|
||||
output: SessionTable.tokens_output,
|
||||
reasoning: SessionTable.tokens_reasoning,
|
||||
cacheRead: SessionTable.tokens_cache_read,
|
||||
cacheWrite: SessionTable.tokens_cache_write,
|
||||
})
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return
|
||||
yield* events.publish(SessionEvent.UsageUpdated, {
|
||||
sessionID,
|
||||
cost: Money.USD.make(row.cost),
|
||||
tokens: {
|
||||
input: row.input,
|
||||
output: row.output,
|
||||
reasoning: row.reasoning,
|
||||
cache: { read: row.cacheRead, write: row.cacheWrite },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
db: DatabaseService,
|
||||
event: typeof SessionEvent.Forked.Type,
|
||||
|
|
@ -187,7 +203,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const copiedSeq = copied?.seq ?? 0
|
||||
const copiedSeq = copied?.seq
|
||||
|
||||
const stored = yield* db
|
||||
.insert(SessionTable)
|
||||
|
|
@ -237,9 +253,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const usage = emptyUsage()
|
||||
let cursor = -1
|
||||
while (true) {
|
||||
while (copiedSeq !== undefined) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
|
|
@ -247,8 +262,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||
gt(SessionMessageTable.seq, cursor),
|
||||
copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1),
|
||||
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') not in ('queued', 'running')`,
|
||||
lt(SessionMessageTable.seq, copiedSeq + 1),
|
||||
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
|
|
@ -271,7 +286,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||
seq: row.seq,
|
||||
time_created: row.time_created,
|
||||
time_updated: row.time_updated,
|
||||
data: row.type === "synthetic" ? { ...row.data, sessionID: event.data.sessionID } : row.data,
|
||||
data: row.data,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
|
@ -318,34 +333,16 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const value = messageUsage(row)
|
||||
if (value) addUsage(usage, value)
|
||||
}
|
||||
cursor = rows.at(-1)!.seq
|
||||
}
|
||||
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
cost: usage.cost,
|
||||
tokens_input: usage.tokens.input,
|
||||
tokens_output: usage.tokens.output,
|
||||
tokens_reasoning: usage.tokens.reasoning,
|
||||
tokens_cache_read: usage.tokens.cache.read,
|
||||
tokens_cache_write: usage.tokens.cache.write,
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
if (copiedSeq > 0) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq)
|
||||
if (copiedSeq !== undefined) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq)
|
||||
})
|
||||
|
||||
function run(db: DatabaseService, event: MessageEvent) {
|
||||
return Effect.gen(function* () {
|
||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const updateMessage = (message: SessionMessage.Message) => {
|
||||
const updateMessage = (message: SessionMessage.Info) => {
|
||||
if (event.durable === undefined)
|
||||
return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
const encoded = encodeMessage(message)
|
||||
|
|
@ -362,7 +359,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
|||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message)
|
||||
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
|
||||
const adapter: SessionMessageUpdater.Adapter = {
|
||||
getModel() {
|
||||
return db
|
||||
|
|
@ -421,7 +418,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
|||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
eq(SessionMessageTable.type, "shell"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.shell.id') = ${shellID}`,
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.shellID') = ${shellID}`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
|
|
@ -442,7 +439,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
|||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') in ('queued', 'running')`,
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
|
|
@ -463,7 +460,7 @@ function run(db: DatabaseService, event: MessageEvent) {
|
|||
})
|
||||
}
|
||||
|
||||
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Message) {
|
||||
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) {
|
||||
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
|
|
@ -670,14 +667,12 @@ const layer = Layer.effectDiscard(
|
|||
Effect.gen(function* () {
|
||||
if (event.durable === undefined)
|
||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
const admitted = yield* SessionInput.projectCompactionAdmitted(db, {
|
||||
yield* SessionInput.projectCompactionAdmitted(db, {
|
||||
admittedSeq: event.durable.seq,
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
timeCreated: event.created,
|
||||
})
|
||||
if (admitted.id !== event.data.inputID) return
|
||||
yield* run(db, event)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
|
||||
|
|
@ -685,20 +680,23 @@ const layer = Layer.effectDiscard(
|
|||
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Skill.Activated, (event) =>
|
||||
insertMessage(db, event, {
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "skill",
|
||||
name: event.data.name,
|
||||
text: event.data.text,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Skill.Activated, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Step.Ended, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
yield* applyUsage(db, event.data.sessionID, event.data)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Step.Failed, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
if (event.data.cost !== undefined && event.data.tokens !== undefined)
|
||||
yield* applyUsage(db, event.data.sessionID, { cost: event.data.cost, tokens: event.data.tokens })
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Text.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
|
||||
|
|
@ -728,22 +726,26 @@ const layer = Layer.effectDiscard(
|
|||
yield* run(db, event)
|
||||
if (event.durable === undefined)
|
||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
yield* SessionInput.settleCompaction(db, {
|
||||
sessionID: event.data.sessionID,
|
||||
handledSeq: event.durable.seq,
|
||||
})
|
||||
if (event.data.reason === "manual")
|
||||
yield* SessionInput.settleCompaction(db, {
|
||||
sessionID: event.data.sessionID,
|
||||
handledSeq: event.durable.seq,
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined },
|
||||
time_updated: DateTime.toEpochMillis(event.created),
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.asVoid),
|
||||
Effect.gen(function* () {
|
||||
const revert = event.data.revert
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
revert: { ...revert, files: revert.files ? [...revert.files] : undefined },
|
||||
time_updated: DateTime.toEpochMillis(event.created),
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.RevertEvent.Cleared, (event) =>
|
||||
db
|
||||
|
|
@ -790,6 +792,17 @@ const layer = Layer.effectDiscard(
|
|||
yield* InstructionCheckpoint.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed]).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (
|
||||
event.type === SessionEvent.Step.Failed.type &&
|
||||
(event.data.cost === undefined || event.data.tokens === undefined)
|
||||
)
|
||||
return Effect.void
|
||||
return publishSessionUsage(db, events, event.data.sessionID)
|
||||
}),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as SessionRevert from "./revert"
|
||||
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { RelativePath } from "../schema"
|
||||
|
|
@ -46,7 +46,7 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
|
|||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const files = new Map<RelativePath, Snapshot.ID>()
|
||||
for (const row of rows) {
|
||||
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
|
||||
|
|
@ -70,7 +70,7 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
|
|||
const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID })
|
||||
const restore = new Map<RelativePath, Snapshot.ID>()
|
||||
if (original) {
|
||||
for (const file of input.session.revert?.files ?? []) restore.set(file.path, original)
|
||||
for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original)
|
||||
}
|
||||
if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
|
||||
if (restore.size) yield* snapshot.restore({ files: restore })
|
||||
|
|
@ -81,10 +81,6 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
|
|||
const revert = {
|
||||
messageID: input.messageID,
|
||||
snapshot: original,
|
||||
diff: files
|
||||
.map((file) => file.patch)
|
||||
.join("")
|
||||
.trim(),
|
||||
files,
|
||||
} satisfies SessionSchema.Info["revert"]
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||
|
|
@ -100,7 +96,7 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio
|
|||
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
|
||||
if (original)
|
||||
yield* snapshot.restore({
|
||||
files: new Map((session.revert.files ?? []).map((file) => [file.path, original])),
|
||||
files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])),
|
||||
})
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.RevertEvent.Cleared, {
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ import {
|
|||
type ProviderErrorEvent,
|
||||
} from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { Database } from "../../database/database"
|
||||
import { EventV2 } from "../../event"
|
||||
import { Location } from "../../location"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PermissionV2 } from "../../permission"
|
||||
import { Instructions } from "../../instructions/index"
|
||||
import { InstructionBuiltIns } from "../../instructions/builtins"
|
||||
|
|
@ -50,6 +52,32 @@ import { StepFailedError, UserInterruptedError } from "../error"
|
|||
import { toSessionError } from "../to-session-error"
|
||||
import { SessionRunnerRetry } from "./retry"
|
||||
import { AgentTelemetry } from "../../observability/agent"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
|
||||
import { PluginHooks } from "../../plugin/hooks"
|
||||
|
||||
type StepTokens = {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
|
||||
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
|
||||
export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) {
|
||||
const context = tokens.input + tokens.cache.read + tokens.cache.write
|
||||
const tier = costs
|
||||
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
|
||||
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
|
||||
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
|
||||
if (!cost) return Money.USD.zero
|
||||
return Money.USD.make(
|
||||
(tokens.input * cost.input +
|
||||
(tokens.output + tokens.reasoning) * cost.output +
|
||||
tokens.cache.read * cost.cache.read +
|
||||
tokens.cache.write * cost.cache.write) /
|
||||
1_000_000,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one durable coding-agent Session until it settles.
|
||||
|
|
@ -108,6 +136,7 @@ const layer = Layer.effect(
|
|||
const llm = yield* LLMClient.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const tools = yield* ToolRegistry.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const location = yield* Location.Service
|
||||
|
|
@ -138,7 +167,7 @@ const layer = Layer.effect(
|
|||
for (const message of yield* store.context(sessionID)) {
|
||||
if (message.type !== "assistant") continue
|
||||
for (const tool of message.content) {
|
||||
if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue
|
||||
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID: message.id,
|
||||
|
|
@ -213,6 +242,7 @@ const layer = Layer.effect(
|
|||
}
|
||||
const resolved = yield* AgentTelemetry.stage("model_resolution", models.resolve(session))
|
||||
const model = resolved.model
|
||||
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
||||
const entries = yield* AgentTelemetry.stage(
|
||||
"history",
|
||||
SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq),
|
||||
|
|
@ -236,17 +266,41 @@ const layer = Layer.effect(
|
|||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [
|
||||
...toLLMMessages(context, resolved.ref),
|
||||
...toLLMMessages(context, resolved.ref, providerMetadataKey),
|
||||
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
|
||||
],
|
||||
tools: toolMaterialization?.definitions ?? [],
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
})
|
||||
const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
|
||||
const requestEvent: SessionHooks["request"] = {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
system: [...request.system],
|
||||
messages: [...request.messages],
|
||||
tools: Object.fromEntries(
|
||||
request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
|
||||
),
|
||||
}
|
||||
// Plugins may reshape the draft, but cannot advertise tools excluded earlier
|
||||
// by permissions or registration state.
|
||||
yield* hooks.trigger("session", "request", requestEvent)
|
||||
const hookedRequest = LLM.updateRequest(request, {
|
||||
system: requestEvent.system,
|
||||
messages: requestEvent.messages,
|
||||
tools: Object.entries(requestEvent.tools).flatMap(([name, tool]) => {
|
||||
const registered = availableTools.get(name)
|
||||
if (!registered) return []
|
||||
return [{ ...registered, description: tool.description, inputSchema: tool.input }]
|
||||
}),
|
||||
})
|
||||
const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name))
|
||||
// Automatic compaction completed; rebuild the request from compacted history.
|
||||
if (!(yield* SessionInput.pendingCompaction(db, session.id))) {
|
||||
const compacted = yield* AgentTelemetry.stage(
|
||||
"compaction",
|
||||
compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }),
|
||||
compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest }),
|
||||
)
|
||||
if (compacted) {
|
||||
yield* AgentTelemetry.compactionCompleted("automatic")
|
||||
|
|
@ -260,7 +314,7 @@ const layer = Layer.effect(
|
|||
// The selected catalog identity, not model.id: route-level ids are provider API
|
||||
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
|
||||
model: resolved.ref,
|
||||
provider: model.provider,
|
||||
providerMetadataKey,
|
||||
snapshot: startSnapshot,
|
||||
assistantMessageID,
|
||||
})
|
||||
|
|
@ -268,8 +322,7 @@ const layer = Layer.effect(
|
|||
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
|
||||
serialized(publisher.publish(event, outputPaths, error))
|
||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const telemetry = AgentTelemetry.modelCall({
|
||||
sessionID: session.id,
|
||||
|
|
@ -283,7 +336,7 @@ const layer = Layer.effect(
|
|||
const providerStream = AgentTelemetry.stage(
|
||||
"model",
|
||||
telemetry.run(
|
||||
llm.stream(request).pipe(
|
||||
llm.stream(hookedRequest).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
|
|
@ -305,6 +358,21 @@ const layer = Layer.effect(
|
|||
)
|
||||
return
|
||||
}
|
||||
// A request hook hid this registered tool from the current request. Fail only
|
||||
// this call durably and continue so the model can react, instead of executing
|
||||
// a tool that was not advertised. Unregistered tools flow through settle, which
|
||||
// durably fails them as unknown.
|
||||
if (!advertisedTools.has(event.name) && availableTools.has(event.name)) {
|
||||
needsContinuation = true
|
||||
yield* publish(
|
||||
LLMEvent.toolError({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
message: `Tool is not available for this request: ${event.name}`,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
needsContinuation = true
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
ownedToolFibers.push(
|
||||
|
|
@ -327,7 +395,6 @@ const layer = Layer.effect(
|
|||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
}),
|
||||
settlement.outputPaths ?? [],
|
||||
settlement.error,
|
||||
),
|
||||
).pipe(
|
||||
|
|
@ -350,6 +417,11 @@ const layer = Layer.effect(
|
|||
),
|
||||
)
|
||||
|
||||
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
|
||||
cost: calculateCost(resolved.cost, settlement.tokens),
|
||||
tokens: settlement.tokens,
|
||||
})
|
||||
|
||||
// Captures the end snapshot, diffs it against the step's start, and durably ends the
|
||||
// assistant step.
|
||||
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
|
||||
|
|
@ -368,8 +440,7 @@ const layer = Layer.effect(
|
|||
sessionID: session.id,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: settlement.finish,
|
||||
cost: 0,
|
||||
tokens: settlement.tokens,
|
||||
...stepUsage(settlement),
|
||||
snapshot: endSnapshot,
|
||||
files,
|
||||
}),
|
||||
|
|
@ -499,7 +570,8 @@ const layer = Layer.effect(
|
|||
!providerFailed &&
|
||||
!stepFailure
|
||||
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
|
||||
if (stepFailure) yield* serialized(publisher.publishStepFailure())
|
||||
if (stepFailure)
|
||||
yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined))
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (userDeclined) return yield* Effect.interrupt
|
||||
|
|
@ -601,6 +673,7 @@ const layer = Layer.effect(
|
|||
return yield* compaction.compactManual({
|
||||
session,
|
||||
messages: yield* store.context(sessionID),
|
||||
inputID: pending.id,
|
||||
})
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
|
|
@ -608,9 +681,27 @@ const layer = Layer.effect(
|
|||
yield* AgentTelemetry.compactionCompleted("manual")
|
||||
return true
|
||||
}
|
||||
yield* events.publish(SessionEvent.Compaction.Failed, { sessionID })
|
||||
if (Exit.isFailure(compacted)) {
|
||||
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
|
||||
if (unsettled)
|
||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
|
||||
inputID: unsettled.id,
|
||||
})
|
||||
yield* AgentTelemetry.compactionFailed("manual")
|
||||
return yield* Effect.failCause(compacted.cause)
|
||||
}
|
||||
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
|
||||
if (unsettled)
|
||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: { type: "compaction.failed", message: "Compaction could not start" },
|
||||
inputID: unsettled.id,
|
||||
})
|
||||
yield* AgentTelemetry.compactionFailed("manual")
|
||||
if (Exit.isFailure(compacted)) return yield* Effect.failCause(compacted.cause)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
|
|
@ -685,6 +776,7 @@ export const node = makeLocationNode({
|
|||
llmClient,
|
||||
AgentV2.node,
|
||||
ToolRegistry.node,
|
||||
PluginHooks.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionStore.node,
|
||||
Location.node,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ export interface Resolved {
|
|||
readonly model: Model
|
||||
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
|
||||
readonly ref: ModelV2.Ref
|
||||
/** Catalog pricing in dollars per million tokens. */
|
||||
readonly cost: ModelV2.Info["cost"]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
|
|
@ -94,13 +96,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||
|
||||
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
|
||||
export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved => ({
|
||||
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
|
||||
model,
|
||||
ref: ModelV2.Ref.make({
|
||||
id: ModelV2.ID.make(model.id),
|
||||
providerID: ProviderV2.ID.make(model.provider),
|
||||
...(variant === undefined ? {} : { variant }),
|
||||
}),
|
||||
cost,
|
||||
})
|
||||
|
||||
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
|
||||
|
|
@ -341,6 +344,7 @@ const layer = Layer.effect(
|
|||
providerID: selected.providerID,
|
||||
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
|
||||
}),
|
||||
cost: selected.cost,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,13 +6,16 @@ import { SessionEvent } from "../event"
|
|||
import { SessionMessage } from "../message"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
|
||||
type Input = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: string
|
||||
readonly agent: AgentV2.ID
|
||||
readonly model: ModelV2.Ref
|
||||
readonly provider: string
|
||||
readonly snapshot?: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly snapshot?: Snapshot.ID
|
||||
readonly assistantMessageID?: SessionMessage.ID
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +87,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
assistantMessageID ??= SessionMessage.ID.create()
|
||||
stepStarted = true
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
...input,
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
assistantMessageID,
|
||||
snapshot: input.snapshot,
|
||||
})
|
||||
|
|
@ -94,34 +99,42 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
assistantMessageID === undefined
|
||||
? Effect.die(new Error("Tool event before assistant step start"))
|
||||
: Effect.succeed(assistantMessageID)
|
||||
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.provider]
|
||||
|
||||
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey]
|
||||
const fragments = (
|
||||
name: string,
|
||||
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
|
||||
single = false,
|
||||
) => {
|
||||
const chunks = new Map<string, { readonly ordinal: number; readonly values: string[] }>()
|
||||
const chunks = new Map<
|
||||
string,
|
||||
{ readonly ordinal: number; readonly values: string[]; state?: Record<string, unknown> }
|
||||
>()
|
||||
let nextOrdinal = 0
|
||||
const start = (id: string) =>
|
||||
const start = (id: string, state?: Record<string, unknown>) =>
|
||||
Effect.suspend(() => {
|
||||
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
|
||||
if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`))
|
||||
const ordinal = nextOrdinal++
|
||||
chunks.set(id, { ordinal, values: [] })
|
||||
chunks.set(id, { ordinal, values: [], state })
|
||||
return Effect.succeed(ordinal)
|
||||
})
|
||||
const append = (id: string, value: string) =>
|
||||
const append = (id: string, value: string, state?: Record<string, unknown>) =>
|
||||
Effect.suspend(() => {
|
||||
const current = chunks.get(id)
|
||||
if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`))
|
||||
current.values.push(value)
|
||||
if (state !== undefined) current.state = { ...current.state, ...state }
|
||||
return Effect.succeed(current.ordinal)
|
||||
})
|
||||
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
|
||||
const current = chunks.get(id)
|
||||
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
|
||||
yield* ended(id, current.values.join(""), current.ordinal, state)
|
||||
yield* ended(
|
||||
id,
|
||||
current.values.join(""),
|
||||
current.ordinal,
|
||||
state === undefined ? current.state : { ...current.state, ...state },
|
||||
)
|
||||
chunks.delete(id)
|
||||
})
|
||||
const flush = Effect.fnUntraced(function* () {
|
||||
|
|
@ -216,7 +229,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
if (replace || stepFailure === undefined) stepFailure = error
|
||||
})
|
||||
|
||||
const publishStepFailure = Effect.fnUntraced(function* () {
|
||||
const publishStepFailure = Effect.fnUntraced(function* (usage?: {
|
||||
readonly cost: Money.USD
|
||||
readonly tokens: ReturnType<typeof tokens>
|
||||
}) {
|
||||
if (stepFailed || stepFailure === undefined) return
|
||||
const assistantMessageID = yield* startAssistant()
|
||||
stepFailed = true
|
||||
|
|
@ -224,6 +240,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
error: stepFailure,
|
||||
...usage,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -252,11 +269,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
|
||||
}
|
||||
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
|
||||
event: LLMEvent,
|
||||
outputPaths: ReadonlyArray<string> = [],
|
||||
error?: SessionError.Error,
|
||||
) {
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) {
|
||||
switch (event.type) {
|
||||
case "step-start":
|
||||
yield* startAssistant()
|
||||
|
|
@ -284,7 +297,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
return
|
||||
case "reasoning-start":
|
||||
retryEvidence = true
|
||||
const startedReasoningOrdinal = yield* reasoning.start(event.id)
|
||||
const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata))
|
||||
yield* events.publish(SessionEvent.Reasoning.Started, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
|
|
@ -293,7 +306,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
})
|
||||
return
|
||||
case "reasoning-delta":
|
||||
const deltaReasoningOrdinal = yield* reasoning.append(event.id, event.text)
|
||||
const deltaReasoningOrdinal = yield* reasoning.append(
|
||||
event.id,
|
||||
event.text,
|
||||
providerState(event.providerMetadata),
|
||||
)
|
||||
yield* events.publish(SessionEvent.Reasoning.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
|
|
@ -336,14 +353,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
|
||||
tool.called = true
|
||||
tool.providerExecuted = event.providerExecuted === true
|
||||
const state = providerState(event.providerMetadata)
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
input: record(event.input),
|
||||
executed: tool.providerExecuted,
|
||||
state,
|
||||
state: providerState(event.providerMetadata),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
@ -378,7 +394,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
...result,
|
||||
outputPaths,
|
||||
...(executed ? { result: event.result } : {}),
|
||||
executed,
|
||||
resultState,
|
||||
|
|
@ -409,12 +424,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
case "step-finish":
|
||||
yield* flush()
|
||||
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
|
||||
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
|
||||
if (event.reason === "content-filter") {
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true)
|
||||
return
|
||||
}
|
||||
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
|
||||
return
|
||||
case "finish":
|
||||
return
|
||||
|
|
|
|||
|
|
@ -21,45 +21,50 @@ const media = (file: FileAttachment): ContentPart => ({
|
|||
metadata: file.description === undefined ? undefined : { description: file.description },
|
||||
})
|
||||
|
||||
const textAttachment = (file: FileAttachment) =>
|
||||
Message.make({
|
||||
role: "user",
|
||||
content: [
|
||||
`Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`,
|
||||
file.description === undefined ? undefined : `Description: ${file.description}`,
|
||||
"",
|
||||
Buffer.from(file.data, "base64").toString("utf8"),
|
||||
]
|
||||
.filter((line): line is string => line !== undefined)
|
||||
.join("\n"),
|
||||
metadata: {
|
||||
attachment: {
|
||||
source: file.source,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
},
|
||||
const textAttachment = (file: FileAttachment): ContentPart => ({
|
||||
type: "text",
|
||||
text: `\n\n${[
|
||||
`Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`,
|
||||
file.description === undefined ? undefined : `Description: ${file.description}`,
|
||||
"",
|
||||
Buffer.from(file.data, "base64").toString("utf8"),
|
||||
]
|
||||
.filter((line): line is string => line !== undefined)
|
||||
.join("\n")}`,
|
||||
metadata: {
|
||||
attachment: {
|
||||
source: file.source,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const directoryAttachment = (file: FileAttachment) =>
|
||||
Message.make({
|
||||
role: "user",
|
||||
content: [
|
||||
`Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
|
||||
file.description === undefined ? undefined : `Description: ${file.description}`,
|
||||
file.data.length === 0 ? undefined : "",
|
||||
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
|
||||
]
|
||||
.filter((line): line is string => line !== undefined)
|
||||
.join("\n"),
|
||||
metadata: {
|
||||
attachment: {
|
||||
source: file.source,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
},
|
||||
const directoryAttachment = (file: FileAttachment): ContentPart => ({
|
||||
type: "text",
|
||||
text: `\n\n${[
|
||||
`Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
|
||||
file.description === undefined ? undefined : `Description: ${file.description}`,
|
||||
file.data.length === 0 ? undefined : "",
|
||||
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
|
||||
]
|
||||
.filter((line): line is string => line !== undefined)
|
||||
.join("\n")}`,
|
||||
metadata: {
|
||||
attachment: {
|
||||
source: file.source,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const attachmentContent = (file: FileAttachment): ContentPart[] => {
|
||||
if (file.mime === "text/plain") return [textAttachment(file)]
|
||||
if (file.mime === "application/x-directory") return [directoryAttachment(file)]
|
||||
if (imageMimes.has(file.mime)) return [media(file)]
|
||||
return []
|
||||
}
|
||||
|
||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
|
|
@ -69,7 +74,7 @@ const providerMetadata = (
|
|||
): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state })
|
||||
|
||||
const toolInput = (tool: SessionMessage.AssistantTool) =>
|
||||
tool.state.status === "pending"
|
||||
tool.state.status === "streaming"
|
||||
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
|
||||
: tool.state.input
|
||||
|
||||
|
|
@ -113,7 +118,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
|||
}
|
||||
}
|
||||
|
||||
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
||||
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, providerMetadataKey: string) => {
|
||||
const sameModel =
|
||||
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
|
||||
const reuseProviderMetadata = sameModel && message.error === undefined
|
||||
|
|
@ -125,7 +130,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
|||
{
|
||||
type: "reasoning",
|
||||
text: item.text,
|
||||
providerMetadata: providerMetadata(model.providerID, item.state),
|
||||
providerMetadata: providerMetadata(providerMetadataKey, item.state),
|
||||
},
|
||||
]
|
||||
: item.text.length > 0
|
||||
|
|
@ -133,13 +138,13 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
|||
: []
|
||||
const call = toolCall(
|
||||
item,
|
||||
reuseProviderMetadata ? providerMetadata(model.providerID, item.providerState) : undefined,
|
||||
reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
|
||||
)
|
||||
if (item.executed !== true) return [call]
|
||||
const result = toolResult(
|
||||
item,
|
||||
reuseProviderMetadata
|
||||
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
|
||||
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
|
||||
: undefined,
|
||||
)
|
||||
return result ? [call, result] : [call]
|
||||
|
|
@ -155,7 +160,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
|||
toolResult(
|
||||
item,
|
||||
reuseProviderMetadata
|
||||
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
|
||||
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
|
|
@ -168,23 +173,22 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
|||
]
|
||||
}
|
||||
|
||||
function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Message[] {
|
||||
function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, providerMetadataKey: string): Message[] {
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
return []
|
||||
case "user":
|
||||
const files = message.files ?? []
|
||||
const content = [
|
||||
...(message.text === "" ? [] : [Message.text(message.text)]),
|
||||
...(message.files ?? []).flatMap(attachmentContent),
|
||||
]
|
||||
if (content.length === 0) return []
|
||||
return [
|
||||
...files.filter((file) => file.mime === "text/plain").map(textAttachment),
|
||||
...files.filter((file) => file.mime === "application/x-directory").map(directoryAttachment),
|
||||
Message.make({
|
||||
id: message.id,
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: message.text },
|
||||
...files.filter((file) => imageMimes.has(file.mime)).map(media),
|
||||
],
|
||||
content,
|
||||
metadata: {
|
||||
...message.metadata,
|
||||
...(message.agents?.length ? { agents: message.agents } : {}),
|
||||
|
|
@ -202,12 +206,12 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess
|
|||
Message.make({
|
||||
id: message.id,
|
||||
role: "user",
|
||||
content: `Shell command: ${message.shell.command}\n\n${message.output?.output ?? ""}`,
|
||||
content: `Shell command: ${message.command}\n\n${message.output?.output ?? ""}`,
|
||||
metadata: message.metadata,
|
||||
}),
|
||||
]
|
||||
case "assistant":
|
||||
return assistant(message, model)
|
||||
return assistant(message, model, providerMetadataKey)
|
||||
case "compaction":
|
||||
if (message.status !== "completed") return []
|
||||
return [
|
||||
|
|
@ -232,5 +236,8 @@ ${message.recent}
|
|||
}
|
||||
|
||||
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
|
||||
export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: ModelV2.Ref) =>
|
||||
messages.flatMap((message) => toLLMMessage(message, model))
|
||||
export const toLLMMessages = (
|
||||
messages: readonly SessionMessage.Info[],
|
||||
model: ModelV2.Ref,
|
||||
providerMetadataKey: string = model.providerID,
|
||||
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey))
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql"
|
|||
import type { SessionMessage } from "./message"
|
||||
import type { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import type { SessionInput } from "./input"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { PermissionV1 } from "../v1/permission"
|
||||
import { ProjectV2 } from "../project"
|
||||
import type { SessionSchema } from "./schema"
|
||||
|
|
@ -13,10 +13,11 @@ import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
|||
import { WorkspaceV2 } from "../workspace"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { Instructions } from "../instructions/index"
|
||||
import type { Revert } from "@opencode-ai/schema/revert"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
|
||||
import type { Schema } from "effect"
|
||||
|
||||
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
||||
type SessionMessageData = Omit<(typeof SessionMessage.Info)["Encoded"], "type" | "id">
|
||||
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
||||
type V1PartData = Omit<SessionV1.Part, "id" | "sessionID" | "messageID">
|
||||
|
||||
|
|
@ -41,7 +42,7 @@ export const SessionTable = sqliteTable(
|
|||
summary_additions: integer(),
|
||||
summary_deletions: integer(),
|
||||
summary_files: integer(),
|
||||
summary_diffs: text({ mode: "json" }).$type<Snapshot.LegacyFileDiff[]>(),
|
||||
summary_diffs: text({ mode: "json" }).$type<FileDiff.LegacyInfo[]>(),
|
||||
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
|
||||
cost: real().notNull().default(0),
|
||||
tokens_input: integer().notNull().default(0),
|
||||
|
|
@ -49,7 +50,7 @@ export const SessionTable = sqliteTable(
|
|||
tokens_reasoning: integer().notNull().default(0),
|
||||
tokens_cache_read: integer().notNull().default(0),
|
||||
tokens_cache_write: integer().notNull().default(0),
|
||||
revert: text({ mode: "json" }).$type<Revert.State>(),
|
||||
revert: text({ mode: "json" }).$type<Session.Revert | RevertV1>(),
|
||||
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
|
||||
agent: text(),
|
||||
model: text({ mode: "json" }).$type<{
|
||||
|
|
@ -148,7 +149,7 @@ export const SessionInputTable = sqliteTable(
|
|||
.$type<SessionSchema.ID>()
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
type: text().$type<SessionInput.Entry["type"]>().notNull(),
|
||||
type: text().$type<SessionInput.Info["type"]>().notNull(),
|
||||
prompt: text({ mode: "json" }).$type<Prompt>(),
|
||||
delivery: text().$type<SessionInput.Delivery>(),
|
||||
admitted_seq: integer().notNull(),
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ import { fromRow } from "./info"
|
|||
|
||||
export interface Interface {
|
||||
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
|
||||
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Message } | undefined>
|
||||
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionStore") {}
|
||||
|
|
@ -25,7 +25,7 @@ const layer = Layer.effect(
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
return Service.of({
|
||||
get: Effect.fn("SessionStore.get")(function* (sessionID) {
|
||||
|
|
|
|||
|
|
@ -28,11 +28,15 @@ export type Source = typeof Source.Type
|
|||
|
||||
export const Info = Skill.Info
|
||||
export type Info = Skill.Info
|
||||
export const ID = Skill.ID
|
||||
export type ID = Skill.ID
|
||||
export const Name = Skill.Name
|
||||
export type Name = Skill.Name
|
||||
|
||||
export const Event = Skill.Event
|
||||
|
||||
export const available = (skills: ReadonlyArray<Info>, agent: AgentV2.Info) =>
|
||||
skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny")
|
||||
skills.filter((skill) => PermissionV2.evaluate("skill", skill.id, agent.permissions).effect !== "deny")
|
||||
|
||||
const Frontmatter = Schema.Struct({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
|
|
@ -96,7 +100,7 @@ const layer = Layer.effect(
|
|||
source: Source.key(source),
|
||||
type: source.type,
|
||||
directories: [],
|
||||
skills: [source.skill.name],
|
||||
skills: [source.skill.id],
|
||||
})
|
||||
return { skills: [source.skill], directories: [] }
|
||||
}
|
||||
|
|
@ -112,15 +116,13 @@ const layer = Layer.effect(
|
|||
if (!markdown) continue
|
||||
const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
|
||||
if (!frontmatter) continue
|
||||
const name =
|
||||
frontmatter.name !== undefined
|
||||
? frontmatter.name
|
||||
: path.dirname(filepath) === directory
|
||||
? path.basename(filepath, ".md")
|
||||
: undefined
|
||||
if (!name) continue
|
||||
const id =
|
||||
path.dirname(filepath) === directory
|
||||
? path.basename(filepath, ".md")
|
||||
: path.basename(path.dirname(filepath))
|
||||
skills.push({
|
||||
name,
|
||||
id: ID.make(id),
|
||||
name: Name.make(frontmatter.name ?? id),
|
||||
description: frontmatter.description,
|
||||
slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash,
|
||||
autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"),
|
||||
|
|
@ -133,7 +135,7 @@ const layer = Layer.effect(
|
|||
source: Source.key(source),
|
||||
type: source.type,
|
||||
directories,
|
||||
skills: skills.map((skill) => skill.name),
|
||||
skills: skills.map((skill) => skill.id),
|
||||
})
|
||||
return { skills, directories }
|
||||
})
|
||||
|
|
@ -148,7 +150,7 @@ const layer = Layer.effect(
|
|||
yield* Effect.logInfo("skill cache invalidated", {
|
||||
file,
|
||||
sources: invalidated.map(([key]) => key),
|
||||
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.name)),
|
||||
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
|
||||
})
|
||||
yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid)
|
||||
})
|
||||
|
|
@ -159,12 +161,12 @@ const layer = Layer.effect(
|
|||
)
|
||||
|
||||
const list = Effect.fn("SkillV2.list")(function* () {
|
||||
const skills = new Map<string, Info>()
|
||||
const skills = new Map<ID, Info>()
|
||||
for (const source of state.get().sources) {
|
||||
const key = Source.key(source)
|
||||
const loaded = cache.get(key) ?? (yield* load(source))
|
||||
cache.set(key, loaded)
|
||||
for (const skill of loaded.skills) skills.set(skill.name, skill)
|
||||
for (const skill of loaded.skills) skills.set(skill.id, skill)
|
||||
}
|
||||
return Array.from(skills.values())
|
||||
})
|
||||
|
|
@ -180,4 +182,8 @@ const layer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node, EventV2.node] })
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SkillDiscovery.node, FSUtil.node, EventV2.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { SkillV2 } from "../skill"
|
|||
import { Instructions } from "../instructions/index"
|
||||
|
||||
const Summary = Schema.Struct({
|
||||
name: Schema.String,
|
||||
id: SkillV2.ID,
|
||||
name: SkillV2.Name,
|
||||
description: Schema.String,
|
||||
})
|
||||
type Summary = typeof Summary.Type
|
||||
|
|
@ -16,6 +17,7 @@ type Summary = typeof Summary.Type
|
|||
const entries = (skills: ReadonlyArray<Summary>) =>
|
||||
skills.flatMap((skill) => [
|
||||
" <skill>",
|
||||
` <id>${skill.id}</id>`,
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
" </skill>",
|
||||
|
|
@ -34,8 +36,8 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
|
|||
const diff = Instructions.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(skill) => skill.name,
|
||||
(before, after) => before.description !== after.description,
|
||||
(skill) => skill.id,
|
||||
(before, after) => before.name !== after.name || before.description !== after.description,
|
||||
)
|
||||
// Additions and removals render as small deltas; anything else restates the full list.
|
||||
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
|
||||
|
|
@ -50,7 +52,7 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
|
|||
...(diff.removed.length === 0
|
||||
? []
|
||||
: [
|
||||
`The following skills are no longer available and must not be used: ${diff.removed.map((skill) => skill.name).join(", ")}.`,
|
||||
`The following skill IDs are no longer available and must not be used: ${diff.removed.map((skill) => skill.id).join(", ")}.`,
|
||||
]),
|
||||
].join("\n")
|
||||
}
|
||||
|
|
@ -77,9 +79,9 @@ const layer = Layer.effect(
|
|||
.flatMap((skill) =>
|
||||
skill.description === undefined || skill.autoinvoke === false
|
||||
? []
|
||||
: [{ name: skill.name, description: skill.description }],
|
||||
: [{ id: skill.id, name: skill.name, description: skill.description }],
|
||||
)
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
return Instructions.make({
|
||||
key: Instructions.Key.make("core/skill-guidance"),
|
||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import { Git } from "./git"
|
|||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath, RelativePath } from "./schema"
|
||||
import { ID } from "@opencode-ai/schema/snapshot"
|
||||
import { Hash } from "./util/hash"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
export { ID }
|
||||
|
||||
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
|
||||
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
|
||||
|
|
@ -253,12 +253,3 @@ function failure(operation: Error["operation"], cause: unknown) {
|
|||
cause,
|
||||
})
|
||||
}
|
||||
|
||||
/** Legacy persisted session diff shape. */
|
||||
export type LegacyFileDiff = {
|
||||
file?: string
|
||||
patch?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ Registrations are scoped:
|
|||
|
||||
## Permissions
|
||||
|
||||
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `apply_patch` declare the shared `edit` action.
|
||||
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `patch` declare the shared `edit` action.
|
||||
|
||||
Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
export * as EditTool from "./edit"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as GlobTool from "./glob"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { FileSystem } from "../filesystem"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as GrepTool from "./grep"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as ApplyPatchTool from "./apply-patch"
|
||||
export * as PatchTool from "./patch"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
|
|
@ -12,7 +12,7 @@ import { Patch } from "../patch"
|
|||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export const name = "apply_patch"
|
||||
export const name = "patch"
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
patchText: Schema.String.annotate({
|
||||
|
|
@ -55,8 +55,8 @@ type Prepared =
|
|||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.apply-patch",
|
||||
effect: Effect.fn("ApplyPatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
|
|
@ -91,11 +91,11 @@ export const Plugin = {
|
|||
if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
|
||||
const hunks = yield* Effect.try({
|
||||
try: () => Patch.parse(input.patchText),
|
||||
catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }),
|
||||
catch: (cause) => new ToolFailure({ message: `patch verification failed: ${String(cause)}` }),
|
||||
})
|
||||
if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
|
||||
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" })
|
||||
if (move) return yield* new ToolFailure({ message: "patch moves are not supported yet" })
|
||||
|
||||
const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
|
||||
for (const hunk of hunks)
|
||||
|
|
@ -194,6 +194,19 @@ export const Plugin = {
|
|||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("request", (event) =>
|
||||
Effect.sync(() => {
|
||||
const usePatch =
|
||||
event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt")
|
||||
if (usePatch) {
|
||||
delete event.tools.edit
|
||||
delete event.tools.write
|
||||
return
|
||||
}
|
||||
delete event.tools.patch
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
export * as QuestionTool from "./question"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Form } from "../form"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as ReadTool from "./read"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { dirname } from "path"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
|
|
|||
|
|
@ -192,12 +192,8 @@ const registryLayer = Layer.effect(
|
|||
const registration = entries.at(-1)?.registration
|
||||
if (registration) registrations.set(name, registration)
|
||||
}
|
||||
// OpenAI/GPT models use apply_patch; every other model uses edit and write.
|
||||
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
|
||||
for (const [name, registration] of registrations) {
|
||||
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
|
||||
if (
|
||||
wrongEditTool ||
|
||||
(registration.deferred && !Flag.CODEMODE_ENABLED) ||
|
||||
whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export * as ShellTool from "./shell"
|
|||
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as SkillTool from "./skill"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
|
@ -13,11 +13,11 @@ export const name = "skill"
|
|||
const FILE_LIMIT = 10
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }),
|
||||
id: SkillV2.ID.annotate({ description: "The ID of the skill from the available skills list" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
name: Schema.String,
|
||||
name: SkillV2.Name,
|
||||
directory: Schema.String,
|
||||
output: Schema.String,
|
||||
})
|
||||
|
|
@ -27,7 +27,7 @@ export const description = [
|
|||
"",
|
||||
"Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.",
|
||||
"",
|
||||
"The skill name must match one of the available skills in the instructions.",
|
||||
"The skill ID must match one of the available skills in the instructions.",
|
||||
].join("\n")
|
||||
|
||||
export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray<string>) => {
|
||||
|
|
@ -70,13 +70,13 @@ export const Plugin = {
|
|||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* skills.list()
|
||||
const skill = current.find((skill) => skill.name === input.name)
|
||||
if (!skill) return yield* unableToLoad(input.name)
|
||||
const skill = current.find((skill) => skill.id === input.id)
|
||||
if (!skill) return yield* unableToLoad(input.id)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [skill.name],
|
||||
save: [skill.name],
|
||||
resources: [skill.id],
|
||||
save: [skill.id],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
|
|
@ -94,7 +94,7 @@ export const Plugin = {
|
|||
directory,
|
||||
output: toModelOutput(skill, files),
|
||||
}
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
export * as SubagentTool from "./subagent"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { PluginRuntime } from "../plugin/runtime"
|
||||
import { ToolTelemetry } from "../observability/tool"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
|
|
@ -43,6 +44,7 @@ export const Plugin = {
|
|||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
|
||||
|
|
@ -115,6 +117,20 @@ export const Plugin = {
|
|||
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
|
||||
if (agent.mode === "primary")
|
||||
return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` })
|
||||
yield* permission
|
||||
.assert({
|
||||
action: name,
|
||||
resources: [agent.id],
|
||||
save: [agent.id],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
},
|
||||
})
|
||||
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
|
||||
|
||||
// Model selection is policy/config/session state, not an LLM-facing tool argument.
|
||||
const model = agent.model ?? parent.model
|
||||
|
|
@ -178,5 +194,32 @@ export const Plugin = {
|
|||
),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("request", (event) =>
|
||||
Effect.gen(function* () {
|
||||
const tool = event.tools[name]
|
||||
if (!tool) return
|
||||
const selected = yield* agents.resolve(event.agent)
|
||||
if (!selected) return
|
||||
const available = (yield* agents.list())
|
||||
.filter(
|
||||
(agent) =>
|
||||
agent.mode !== "primary" &&
|
||||
!agent.hidden &&
|
||||
PermissionV2.evaluate(name, agent.id, selected.permissions).effect !== "deny",
|
||||
)
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
if (available.length === 0) return
|
||||
tool.description = [
|
||||
tool.description,
|
||||
"",
|
||||
"Available subagents:",
|
||||
...available.map(
|
||||
(agent) =>
|
||||
`- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`,
|
||||
),
|
||||
].join("\n")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as TodoWriteTool from "./todowrite"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { PermissionV2 } from "../permission"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as WebFetchTool from "./webfetch"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export * as WebSearchTool from "./websearch"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
export * as WriteTool from "./write"
|
||||
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
|
|
|
|||
|
|
@ -36,20 +36,24 @@ export namespace EffectFlock {
|
|||
|
||||
export type LockError = LockTimeoutError | LockCompromisedError
|
||||
|
||||
export interface Options {
|
||||
readonly staleMs?: number
|
||||
readonly timeoutMs?: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timing (baked in — no caller ever overrides these)
|
||||
// Timing defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STALE_MS = 60_000
|
||||
const TIMEOUT_MS = 5 * 60_000
|
||||
const DEFAULT_STALE_MS = 60_000
|
||||
const DEFAULT_TIMEOUT_MS = 5 * 60_000
|
||||
const BASE_DELAY_MS = 100
|
||||
const MAX_DELAY_MS = 2_000
|
||||
const HEARTBEAT_MS = Math.max(100, Math.floor(STALE_MS / 3))
|
||||
|
||||
const retrySchedule = Schedule.exponential(BASE_DELAY_MS, 1.7).pipe(
|
||||
Schedule.either(Schedule.spaced(MAX_DELAY_MS)),
|
||||
const retrySchedule = (timeoutMs: number) => Schedule.exponential(BASE_DELAY_MS, 1.7).pipe(
|
||||
Schedule.either(Schedule.spaced(Math.min(MAX_DELAY_MS, Math.max(BASE_DELAY_MS, Math.floor(timeoutMs / 10))))),
|
||||
Schedule.jittered,
|
||||
Schedule.while((meta) => meta.elapsed < TIMEOUT_MS),
|
||||
Schedule.while((meta) => meta.elapsed < timeoutMs),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -73,7 +77,7 @@ export namespace EffectFlock {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Interface {
|
||||
readonly acquire: (key: string, dir?: string) => Effect.Effect<void, LockError, Scope.Scope>
|
||||
readonly acquire: (key: string, dir?: string, options?: Options) => Effect.Effect<void, LockError, Scope.Scope>
|
||||
readonly withLock: {
|
||||
(key: string, dir?: string): <A, E, R>(body: Effect.Effect<A, E, R>) => Effect.Effect<A, E | LockError, R>
|
||||
<A, E, R>(body: Effect.Effect<A, E, R>, key: string, dir?: string): Effect.Effect<A, E | LockError, R>
|
||||
|
|
@ -135,9 +139,9 @@ export namespace EffectFlock {
|
|||
),
|
||||
)
|
||||
|
||||
const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string) {
|
||||
const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string, staleMs: number) {
|
||||
const bs = yield* safeStat(breakerPath)
|
||||
if (bs && wall() - mtimeMs(bs) > STALE_MS) yield* forceRemove(breakerPath)
|
||||
if (bs && wall() - mtimeMs(bs) > staleMs) yield* forceRemove(breakerPath)
|
||||
return false
|
||||
})
|
||||
|
||||
|
|
@ -147,26 +151,31 @@ export namespace EffectFlock {
|
|||
ensuredDirs.add(dir)
|
||||
})
|
||||
|
||||
const isStale = Effect.fnUntraced(function* (lockDir: string, heartbeatPath: string, metaPath: string) {
|
||||
const isStale = Effect.fnUntraced(function* (
|
||||
lockDir: string,
|
||||
heartbeatPath: string,
|
||||
metaPath: string,
|
||||
staleMs: number,
|
||||
) {
|
||||
const now = wall()
|
||||
|
||||
const hb = yield* safeStat(heartbeatPath)
|
||||
if (hb) return now - mtimeMs(hb) > STALE_MS
|
||||
if (hb) return now - mtimeMs(hb) > staleMs
|
||||
|
||||
const meta = yield* safeStat(metaPath)
|
||||
if (meta) return now - mtimeMs(meta) > STALE_MS
|
||||
if (meta) return now - mtimeMs(meta) > staleMs
|
||||
|
||||
const dir = yield* safeStat(lockDir)
|
||||
if (!dir) return false
|
||||
|
||||
return now - mtimeMs(dir) > STALE_MS
|
||||
return now - mtimeMs(dir) > staleMs
|
||||
})
|
||||
|
||||
// -- single lock attempt --
|
||||
|
||||
type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string }
|
||||
|
||||
const tryAcquireLockDir = (lockDir: string, key: string) =>
|
||||
const tryAcquireLockDir = (lockDir: string, key: string, staleMs: number) =>
|
||||
Effect.gen(function* () {
|
||||
const token = randomUUID()
|
||||
const metaPath = path.join(lockDir, "meta.json")
|
||||
|
|
@ -176,7 +185,7 @@ export namespace EffectFlock {
|
|||
const created = yield* atomicMkdir(lockDir)
|
||||
|
||||
if (!created) {
|
||||
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired()
|
||||
if (!(yield* isStale(lockDir, heartbeatPath, metaPath, staleMs))) return yield* new NotAcquired()
|
||||
|
||||
// Stale — race for breaker ownership
|
||||
const breakerPath = lockDir + ".breaker"
|
||||
|
|
@ -185,7 +194,7 @@ export namespace EffectFlock {
|
|||
Effect.as(true),
|
||||
Effect.catchIf(
|
||||
(e) => e.reason._tag === "AlreadyExists",
|
||||
() => cleanStaleBreaker(breakerPath),
|
||||
() => cleanStaleBreaker(breakerPath, staleMs),
|
||||
),
|
||||
Effect.catchIf(isPathGone, () => Effect.succeed(false)),
|
||||
Effect.orDie,
|
||||
|
|
@ -195,7 +204,7 @@ export namespace EffectFlock {
|
|||
|
||||
// We own the breaker — double-check staleness, nuke, recreate
|
||||
const recreated = yield* Effect.gen(function* () {
|
||||
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false
|
||||
if (!(yield* isStale(lockDir, heartbeatPath, metaPath, staleMs))) return false
|
||||
yield* forceRemove(lockDir)
|
||||
return yield* atomicMkdir(lockDir)
|
||||
}).pipe(Effect.ensuring(forceRemove(breakerPath)))
|
||||
|
|
@ -218,13 +227,21 @@ export namespace EffectFlock {
|
|||
|
||||
// -- retry wrapper (preserves Handle type) --
|
||||
|
||||
const acquireHandle = (lockfile: string, key: string): Effect.Effect<Handle, LockError> =>
|
||||
tryAcquireLockDir(lockfile, key).pipe(
|
||||
const acquireHandle = (
|
||||
lockfile: string,
|
||||
key: string,
|
||||
options: { staleMs: number; timeoutMs: number },
|
||||
): Effect.Effect<Handle, LockError> =>
|
||||
tryAcquireLockDir(lockfile, key, options.staleMs).pipe(
|
||||
Effect.retry({
|
||||
while: (err) => err._tag === "NotAcquired",
|
||||
schedule: retrySchedule,
|
||||
schedule: retrySchedule(options.timeoutMs),
|
||||
}),
|
||||
Effect.catchTag("NotAcquired", () => Effect.fail(new LockTimeoutError({ key }))),
|
||||
Effect.timeoutOrElse({
|
||||
duration: options.timeoutMs,
|
||||
orElse: () => Effect.fail(new LockTimeoutError({ key })),
|
||||
}),
|
||||
)
|
||||
|
||||
// -- release --
|
||||
|
|
@ -250,19 +267,27 @@ export namespace EffectFlock {
|
|||
|
||||
// -- build service --
|
||||
|
||||
const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string) {
|
||||
const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string, options: Options = {}) {
|
||||
const lockDir = dir ?? lockRoot
|
||||
const staleMs = options.staleMs ?? DEFAULT_STALE_MS
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
yield* ensureDir(lockDir)
|
||||
|
||||
const lockfile = path.join(lockDir, Hash.fast(key) + ".lock")
|
||||
|
||||
// acquireRelease: acquire is uninterruptible, release is guaranteed
|
||||
const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key), (handle) => release(handle))
|
||||
const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key, { staleMs, timeoutMs }), (handle) =>
|
||||
release(handle),
|
||||
)
|
||||
|
||||
// Heartbeat fiber — scoped, so it's interrupted before release runs
|
||||
yield* fs
|
||||
.utimes(handle.heartbeatPath, new Date(), new Date())
|
||||
.pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped)
|
||||
.pipe(
|
||||
Effect.ignore,
|
||||
Effect.repeat(Schedule.spaced(Math.max(100, Math.floor(staleMs / 3)))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
})
|
||||
|
||||
const withLock: Interface["withLock"] = Function.dual(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"
|
|||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { LLM } from "@opencode-ai/llm"
|
||||
import { LLM, Message } from "@opencode-ai/llm"
|
||||
import { LLMClient } from "@opencode-ai/llm/route"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
|
|
@ -51,13 +51,11 @@ it.effect("projects request settings, headers, and body overlays", () =>
|
|||
apiKey: "secret",
|
||||
thinkingConfig: { thinkingBudget: 1024 },
|
||||
})
|
||||
const resolved = yield* aisdk.model(
|
||||
{
|
||||
...input,
|
||||
headers: { "x-test": "header" },
|
||||
body: { safety_setting: "strict" },
|
||||
},
|
||||
)
|
||||
const resolved = yield* aisdk.model({
|
||||
...input,
|
||||
headers: { "x-test": "header" },
|
||||
body: { safety_setting: "strict" },
|
||||
})
|
||||
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
|
||||
LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
)
|
||||
|
|
@ -69,3 +67,54 @@ it.effect("projects request settings, headers, and body overlays", () =>
|
|||
expect(body).toEqual({ safety_setting: "strict" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("@ai-sdk/anthropic"))
|
||||
expect(resolved.route.providerMetadataKey).toBe("anthropic")
|
||||
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { anthropic: { blockType: "server_tool_use" } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.prompt).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Think",
|
||||
providerOptions: { anthropic: { signature: "signed" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "hosted",
|
||||
toolName: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerOptions: { anthropic: { blockType: "server_tool_use" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
|
|
@ -298,13 +299,31 @@ describe("CatalogV2", () => {
|
|||
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
|
||||
model.cost = [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(1),
|
||||
output: Money.USDPerMillionTokens.make(1),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.zero,
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
]
|
||||
model.time.released = Date.now()
|
||||
})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
|
||||
model.capabilities.input = ["text"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
|
||||
model.cost = [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(10),
|
||||
output: Money.USDPerMillionTokens.make(10),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.zero,
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
]
|
||||
model.time.released = Date.now()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { describe, expect } from "bun:test"
|
|||
import { Effect, PubSub, Schema, Stream } from "effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
|
|
@ -87,7 +88,7 @@ Review files`,
|
|||
name: "review",
|
||||
template: "Review files",
|
||||
description: "File review",
|
||||
agent: "reviewer",
|
||||
agent: AgentV2.ID.make("reviewer"),
|
||||
model: {
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
id: ModelV2.ID.make("claude"),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { define } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default define({
|
||||
export default Plugin.define({
|
||||
id: "directory-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { define } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Plugin } from "@opencode-ai/plugin/v2"
|
||||
|
||||
export default define({
|
||||
export default Plugin.define({
|
||||
id: "folder-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import fs from "fs/promises"
|
|||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
|
|
@ -178,7 +178,7 @@ describe("PluginSupervisor config", () => {
|
|||
it.live("loads user plugins before internal post plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{
|
||||
plugins: [
|
||||
|
|
@ -273,9 +273,9 @@ function withLocation<A, E, R>(
|
|||
function mutablePlugin(description: string) {
|
||||
const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/v2/promise/index.ts")).href
|
||||
return `
|
||||
import { define } from ${JSON.stringify(plugin)}
|
||||
import { Plugin } from ${JSON.stringify(plugin)}
|
||||
|
||||
export default define({
|
||||
export default Plugin.define({
|
||||
id: "mutable-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
|
|
@ -253,7 +254,17 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
|
||||
expect(model.cost).toEqual([
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(1),
|
||||
output: Money.USDPerMillionTokens.make(2),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.zero,
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
tier: undefined,
|
||||
},
|
||||
])
|
||||
expect(model.settings).toEqual({ baseURL: "https://example.test", retained: true })
|
||||
expect(model.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.variants?.map((variant) => variant.id)).toEqual([
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { fileURLToPath } from "url"
|
|||
import path from "path"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { eq, inArray, sql } from "drizzle-orm"
|
||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||
import { migrations } from "@opencode-ai/core/database/migration.gen"
|
||||
|
|
@ -17,6 +17,7 @@ import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/
|
|||
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
||||
import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
|
||||
import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox"
|
||||
import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state"
|
||||
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
|
||||
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
|
|
@ -26,6 +27,7 @@ import { ProjectV2 } from "@opencode-ai/core/project"
|
|||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
||||
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
||||
|
|
@ -42,6 +44,256 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
|||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
|
||||
describe("DatabaseMigration", () => {
|
||||
test("migrates pre-launch V2 state in place", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, prompt text, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, created integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL, owner_id text)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL)`,
|
||||
)
|
||||
const messages = [
|
||||
["msg_skill", "skill", { name: "effect", text: "Use Effect", time: { created: 1 } }],
|
||||
[
|
||||
"msg_shell",
|
||||
"shell",
|
||||
{
|
||||
shell: { id: "sh_old", command: "pwd", status: "exited", exit: 0, cwd: "/tmp" },
|
||||
output: { output: "/tmp", cursor: 4, size: 4, truncated: false },
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
],
|
||||
[
|
||||
"msg_assistant",
|
||||
"assistant",
|
||||
{
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_old",
|
||||
name: "read",
|
||||
provider: "removed",
|
||||
state: { status: "pending", input: '{"path":"README.md"}', title: "removed" },
|
||||
time: { created: 3 },
|
||||
},
|
||||
],
|
||||
time: { created: 3 },
|
||||
},
|
||||
],
|
||||
[
|
||||
"msg_failed",
|
||||
"compaction",
|
||||
{
|
||||
status: "failed",
|
||||
reason: "manual",
|
||||
summary: "removed",
|
||||
recent: "removed",
|
||||
time: { created: 4 },
|
||||
},
|
||||
],
|
||||
[
|
||||
"msg_queued",
|
||||
"compaction",
|
||||
{ status: "queued", reason: "manual", summary: "", recent: "", time: { created: 5 } },
|
||||
],
|
||||
[
|
||||
"msg_synthetic",
|
||||
"synthetic",
|
||||
{ sessionID: "ses_test", text: "context", description: "source", time: { created: 6 } },
|
||||
],
|
||||
[
|
||||
"msg_running",
|
||||
"compaction",
|
||||
{ status: "running", reason: "auto", summary: "partial", recent: "recent", time: { created: 7 } },
|
||||
],
|
||||
[
|
||||
"msg_completed",
|
||||
"compaction",
|
||||
{ status: "completed", reason: "auto", summary: "summary", recent: "recent", time: { created: 8 } },
|
||||
],
|
||||
] as const
|
||||
for (const [id, type, data] of messages)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message VALUES (${id}, 'ses_test', ${type}, 1, 10, 11, ${JSON.stringify(data)})`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_input VALUES ('msg_queued', 'ses_test', 'compaction', NULL, NULL, 4, NULL, 5)`,
|
||||
)
|
||||
yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_test', 9, 'owner')`)
|
||||
yield* db.run(sql`INSERT INTO instruction_checkpoint VALUES ('ses_test', 'baseline', '{"source":"value"}', 7)`)
|
||||
const events = [
|
||||
["evt_skill", 1, 101, "session.skill.activated.1", { sessionID: "ses_test", name: "effect", text: "Use" }],
|
||||
["evt_started", 2, 102, "session.compaction.started.1", { sessionID: "ses_test", reason: "auto" }],
|
||||
["evt_delta", 3, 103, "session.compaction.delta.1", { sessionID: "ses_test", text: "partial" }],
|
||||
["evt_failed", 4, 104, "session.compaction.failed.1", { sessionID: "ses_test" }],
|
||||
[
|
||||
"evt_revert",
|
||||
5,
|
||||
105,
|
||||
"session.revert.staged.1",
|
||||
{
|
||||
sessionID: "ses_test",
|
||||
revert: {
|
||||
messageID: "msg_skill",
|
||||
snapshot: "tree",
|
||||
diff: "removed",
|
||||
files: [{ path: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"evt_skill_current",
|
||||
6,
|
||||
106,
|
||||
"session.skill.activated.2",
|
||||
{ sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" },
|
||||
],
|
||||
] as const
|
||||
for (const [id, seq, created, type, data] of events)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO event VALUES (${id}, 'ses_test', ${seq}, ${created}, ${type}, ${JSON.stringify(data)})`,
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [migratePrelaunchV2StateMigration])
|
||||
|
||||
const rows = yield* db.all<{
|
||||
id: string
|
||||
type: string
|
||||
seq: number
|
||||
time_created: number
|
||||
time_updated: number
|
||||
data: string
|
||||
}>(sql`SELECT id, type, seq, time_created, time_updated, data FROM session_message ORDER BY id`)
|
||||
for (const row of rows)
|
||||
Schema.decodeUnknownSync(SessionMessage.Info)({ ...JSON.parse(row.data), id: row.id, type: row.type })
|
||||
expect(rows.every((row) => row.seq === 1 && row.time_created === 10 && row.time_updated === 11)).toBe(true)
|
||||
expect(rows.map((row) => [row.id, JSON.parse(row.data)])).toEqual([
|
||||
[
|
||||
"msg_assistant",
|
||||
expect.objectContaining({
|
||||
content: [expect.objectContaining({ state: { status: "streaming", input: '{"path":"README.md"}' } })],
|
||||
}),
|
||||
],
|
||||
["msg_completed", expect.objectContaining({ status: "completed", summary: "summary", recent: "recent" })],
|
||||
[
|
||||
"msg_failed",
|
||||
{
|
||||
time: { created: 4 },
|
||||
status: "failed",
|
||||
reason: "manual",
|
||||
error: {
|
||||
type: "compaction.failed",
|
||||
message: "Compaction failed before recording an error",
|
||||
},
|
||||
},
|
||||
],
|
||||
["msg_running", expect.objectContaining({ status: "running", summary: "partial", recent: "recent" })],
|
||||
["msg_shell", expect.objectContaining({ shellID: "sh_old", command: "pwd", status: "exited", exit: 0 })],
|
||||
["msg_skill", { time: { created: 1 }, skill: "effect", name: "effect", text: "Use Effect" }],
|
||||
["msg_synthetic", { time: { created: 6 }, text: "context", description: "source" }],
|
||||
])
|
||||
expect(yield* db.get(sql`SELECT * FROM session_input`)).toEqual({
|
||||
id: "msg_queued",
|
||||
session_id: "ses_test",
|
||||
type: "compaction",
|
||||
prompt: null,
|
||||
delivery: null,
|
||||
admitted_seq: 4,
|
||||
promoted_seq: null,
|
||||
time_created: 5,
|
||||
})
|
||||
const migratedEvents = yield* db.all<{
|
||||
id: string
|
||||
aggregate_id: string
|
||||
seq: number
|
||||
created: number
|
||||
type: string
|
||||
data: string
|
||||
}>(sql`SELECT * FROM event ORDER BY seq`)
|
||||
expect(migratedEvents.map((event) => ({ ...event, data: JSON.parse(event.data) }))).toEqual([
|
||||
{
|
||||
id: "evt_skill",
|
||||
aggregate_id: "ses_test",
|
||||
seq: 1,
|
||||
created: 101,
|
||||
type: "session.skill.activated.1",
|
||||
data: { sessionID: "ses_test", id: "effect", name: "effect", text: "Use" },
|
||||
},
|
||||
{
|
||||
id: "evt_started",
|
||||
aggregate_id: "ses_test",
|
||||
seq: 2,
|
||||
created: 102,
|
||||
type: "session.compaction.started.1",
|
||||
data: { sessionID: "ses_test", reason: "auto", recent: "" },
|
||||
},
|
||||
{
|
||||
id: "evt_failed",
|
||||
aggregate_id: "ses_test",
|
||||
seq: 4,
|
||||
created: 104,
|
||||
type: "session.compaction.failed.1",
|
||||
data: {
|
||||
sessionID: "ses_test",
|
||||
reason: "auto",
|
||||
error: {
|
||||
type: "compaction.failed",
|
||||
message: "Compaction failed before recording an error",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt_revert",
|
||||
aggregate_id: "ses_test",
|
||||
seq: 5,
|
||||
created: 105,
|
||||
type: "session.revert.staged.1",
|
||||
data: {
|
||||
sessionID: "ses_test",
|
||||
revert: {
|
||||
messageID: "msg_skill",
|
||||
snapshot: "tree",
|
||||
files: [{ file: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt_skill_current",
|
||||
aggregate_id: "ses_test",
|
||||
seq: 6,
|
||||
created: 106,
|
||||
type: "session.skill.activated.1",
|
||||
data: { sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" },
|
||||
},
|
||||
])
|
||||
expect(yield* db.get(sql`SELECT * FROM event_sequence`)).toEqual({
|
||||
aggregate_id: "ses_test",
|
||||
seq: 9,
|
||||
owner_id: "owner",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT * FROM instruction_checkpoint`)).toEqual({
|
||||
session_id: "ses_test",
|
||||
baseline: "baseline",
|
||||
snapshot: '{"source":"value"}',
|
||||
baseline_seq: 7,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("resets incompatible V2 Session event history", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
|
|
|||
|
|
@ -4,16 +4,27 @@ import {
|
|||
CallToolRequestSchema,
|
||||
GetPromptRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
const server = new Server({ name: "timeout", version: "1.0.0" }, { capabilities: { prompts: {}, tools: {} } })
|
||||
const server = new Server(
|
||||
{ name: "timeout", version: "1.0.0" },
|
||||
{ capabilities: { prompts: {}, resources: {}, tools: {} } },
|
||||
)
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100)
|
||||
return { tools: [{ name: "slow", inputSchema: { type: "object" } }] }
|
||||
})
|
||||
server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] }))
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||
if (process.env.MCP_TIMEOUT_TARGET === "resource-catalog") await Bun.sleep(100)
|
||||
return { resources: [{ name: "slow", uri: "test://slow" }] }
|
||||
})
|
||||
server.setRequestHandler(ListResourceTemplatesRequestSchema, () => Promise.resolve({ resourceTemplates: [] }))
|
||||
server.setRequestHandler(CallToolRequestSchema, async () => {
|
||||
await Bun.sleep(100)
|
||||
return { content: [] }
|
||||
|
|
@ -22,5 +33,9 @@ server.setRequestHandler(GetPromptRequestSchema, async () => {
|
|||
await Bun.sleep(100)
|
||||
return { messages: [] }
|
||||
})
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||
await Bun.sleep(100)
|
||||
return { contents: [{ uri: request.params.uri, text: "slow" }] }
|
||||
})
|
||||
|
||||
await server.connect(new StdioServerTransport())
|
||||
|
|
|
|||
|
|
@ -14,15 +14,12 @@ export const emptyMcpLayer = Layer.succeed(
|
|||
instructions: () => Effect.succeed([]),
|
||||
prompts: () => Effect.succeed([]),
|
||||
prompt: () => Effect.succeed(undefined),
|
||||
resourceCatalog: () => Effect.succeed(new MCP.ResourceCatalog({ resources: [], templates: [] })),
|
||||
resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })),
|
||||
readResource: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)
|
||||
|
||||
export const emptyConfigLayer = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({ entries: () => Effect.succeed([]) }),
|
||||
)
|
||||
export const emptyConfigLayer = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||
|
||||
export const testLocationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ describe("Git trees", () => {
|
|||
RelativePath.make("scope/tracked.txt"),
|
||||
])
|
||||
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 })
|
||||
expect(diffs.map((item) => [item.path, item.status])).toEqual([
|
||||
expect(diffs.map((item) => [item.file, item.status])).toEqual([
|
||||
[RelativePath.make("scope/added.txt"), "added"],
|
||||
[RelativePath.make("scope/tracked.txt"), "modified"],
|
||||
])
|
||||
|
|
@ -154,7 +154,7 @@ describe("Git trees", () => {
|
|||
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||
const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
|
||||
expect(preview).toHaveLength(1)
|
||||
expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt"))
|
||||
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
|
||||
yield* git.tree.restore({ repository, files })
|
||||
expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
|
||||
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
|||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { Tools } from "@opencode-ai/core/tool/tools"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, type Scope } from "effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
export const toolIdentity = {
|
||||
agent: AgentV2.ID.make("build"),
|
||||
|
|
@ -48,7 +49,7 @@ export const registerToolPlugin = <R>(plugin: {
|
|||
}): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const context: Pick<PluginContext, "tool"> = {
|
||||
const context = host({
|
||||
tool: {
|
||||
transform: (callback) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -66,15 +67,13 @@ export const registerToolPlugin = <R>(plugin: {
|
|||
registrations,
|
||||
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
|
||||
{ discard: true },
|
||||
)
|
||||
).pipe(Effect.orDie)
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
execute: {
|
||||
before: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
after: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
},
|
||||
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
},
|
||||
}
|
||||
yield* plugin.effect(context as PluginContext)
|
||||
})
|
||||
yield* plugin.effect(context)
|
||||
})
|
||||
|
||||
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import path from "path"
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Context, DateTime, Effect, Equal, Hash, Schema, Stream } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
|
|
@ -12,6 +13,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
|
|
@ -28,8 +30,43 @@ import { Reference } from "../src/reference"
|
|||
import { ToolRegistry } from "../src/tool/registry"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))
|
||||
const itWithSdk = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])),
|
||||
)
|
||||
|
||||
describe("LocationServiceMap", () => {
|
||||
itWithSdk.live("preserves embedded SDK plugins after Location eviction", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const id = AgentV2.ID.make("persistent-sdk-agent")
|
||||
const plugin = EffectPlugin.define({
|
||||
id: "persistent-sdk-plugin",
|
||||
effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})),
|
||||
})
|
||||
yield* sdk.register(plugin)
|
||||
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const read = Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.ready
|
||||
const agents = yield* AgentV2.Service
|
||||
return yield* agents.get(id)
|
||||
})
|
||||
|
||||
expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined()
|
||||
yield* locations.invalidate(ref)
|
||||
expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("applies ordered plugin config operations during boot", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
@ -172,6 +209,36 @@ describe("LocationServiceMap", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("normalizes ref key shapes to one cached location graph", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const directory = AbsolutePath.make(dir.path)
|
||||
const absent = Location.Ref.make({ directory })
|
||||
const present = Location.Ref.make({ directory, workspaceID: undefined })
|
||||
// The two shapes are not structurally Equal: own-key sets differ.
|
||||
expect(Object.keys(absent)).toEqual(["directory"])
|
||||
expect(Object.keys(present)).toEqual(["directory", "workspaceID"])
|
||||
expect(Equal.equals(absent, present)).toBe(false)
|
||||
|
||||
const first = yield* locations.contextEffect(absent)
|
||||
expect(yield* locations.contextEffect(present)).toBe(first)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1)
|
||||
|
||||
// Invalidating with the shape opposite to the one that booted must evict.
|
||||
yield* locations.invalidate(present)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("isolates catalog state by location", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
|
|
@ -224,6 +291,7 @@ describe("LocationServiceMap", () => {
|
|||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"patch",
|
||||
"question",
|
||||
"read",
|
||||
"shell",
|
||||
|
|
@ -241,6 +309,7 @@ describe("LocationServiceMap", () => {
|
|||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"patch",
|
||||
"question",
|
||||
"read",
|
||||
"shell",
|
||||
|
|
@ -288,7 +357,7 @@ describe("LocationServiceMap", () => {
|
|||
id: ModelV2.ID.make("chat"),
|
||||
providerID: ProviderV2.ID.make("unavailable"),
|
||||
},
|
||||
cost: 0,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location,
|
||||
|
|
@ -337,7 +406,7 @@ describe("LocationServiceMap", () => {
|
|||
providerID: ProviderV2.ID.make("aliased"),
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
},
|
||||
cost: 0,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location,
|
||||
|
|
@ -366,7 +435,7 @@ describe("LocationServiceMap", () => {
|
|||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const reviewer = define({
|
||||
const reviewer = EffectPlugin.define({
|
||||
id: "reviewer",
|
||||
effect: (ctx) =>
|
||||
ctx.agent
|
||||
|
|
|
|||
|
|
@ -3,27 +3,166 @@ import { describe, expect, test } from "bun:test"
|
|||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { ConfigMCP } from "@opencode-ai/core/config/mcp"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { MCPClient } from "@opencode-ai/core/mcp/client"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ATTR_ERROR_TYPE } from "@opencode-ai/core/observability/semconv"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream, Tracer } from "effect"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Stream, Tracer } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { location } from "./fixture/location"
|
||||
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
|
||||
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
|
||||
let calls = 0
|
||||
|
||||
type ResourcePage = {
|
||||
items: Array<{ name: string; uri: string; description?: string; mimeType?: string }>
|
||||
nextCursor?: string
|
||||
}
|
||||
|
||||
type ResourceTemplatePage = {
|
||||
items: Array<{ name: string; uriTemplate: string; description?: string; mimeType?: string }>
|
||||
nextCursor?: string
|
||||
}
|
||||
|
||||
function resourceServer(input: { resources?: boolean; listChanged?: boolean } = {}) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const state = {
|
||||
resources: [] as ResourcePage["items"],
|
||||
templates: [] as ResourceTemplatePage["items"],
|
||||
resourcePages: undefined as Record<string, ResourcePage> | undefined,
|
||||
templatePages: undefined as Record<string, ResourceTemplatePage> | undefined,
|
||||
contents: [
|
||||
{ uri: "docs://readme", text: "hello", mimeType: "text/plain" },
|
||||
{ uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
|
||||
] as Array<{ uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string }>,
|
||||
resourceLists: 0,
|
||||
templateLists: 0,
|
||||
}
|
||||
const protocol = new Server(
|
||||
{ name: "mcp-resources", version: "1.0.0" },
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
...(input.resources === false ? {} : { resources: { listChanged: input.listChanged } }),
|
||||
},
|
||||
},
|
||||
)
|
||||
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
|
||||
if (input.resources !== false) {
|
||||
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
|
||||
state.resourceLists += 1
|
||||
const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
|
||||
return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
|
||||
})
|
||||
protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
|
||||
state.templateLists += 1
|
||||
const page = state.templatePages?.[request.params?.cursor ?? "initial"]
|
||||
return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor })
|
||||
})
|
||||
protocol.setRequestHandler(ReadResourceRequestSchema, () => Promise.resolve({ contents: state.contents }))
|
||||
}
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
enableJsonResponse: true,
|
||||
})
|
||||
await protocol.connect(transport)
|
||||
const http = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => transport.handleRequest(request),
|
||||
})
|
||||
return {
|
||||
state,
|
||||
url: http.url.toString(),
|
||||
sendResourceListChanged: () => protocol.sendResourceListChanged(),
|
||||
close: async () => {
|
||||
await protocol.close().catch(() => {})
|
||||
await http.stop(true)
|
||||
},
|
||||
}
|
||||
}),
|
||||
(server) => Effect.promise(server.close),
|
||||
)
|
||||
}
|
||||
|
||||
function resourceMcpLayer(url: string) {
|
||||
const directory = AbsolutePath.make(import.meta.dir)
|
||||
const unusedIntegration = () => Effect.die("unused integration service")
|
||||
return MCP.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
mcp: new ConfigMCP.Info({
|
||||
servers: { resources: new ConfigMCP.Remote({ type: "remote", url, oauth: false }) },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
|
||||
Layer.mock(EventV2.Service, {
|
||||
subscribe: () => Stream.never,
|
||||
publish: (definition, data) =>
|
||||
Effect.succeed({
|
||||
id: EventV2.ID.create(),
|
||||
type: definition.type,
|
||||
data,
|
||||
} as EventV2.Payload<typeof definition>),
|
||||
}),
|
||||
Layer.mock(Form.Service, {}),
|
||||
Layer.mock(Integration.Service, {
|
||||
connection: {
|
||||
active: unusedIntegration,
|
||||
resolve: unusedIntegration,
|
||||
key: unusedIntegration,
|
||||
oauth: unusedIntegration,
|
||||
update: unusedIntegration,
|
||||
remove: unusedIntegration,
|
||||
},
|
||||
attempt: {
|
||||
status: unusedIntegration,
|
||||
complete: unusedIntegration,
|
||||
cancel: unusedIntegration,
|
||||
},
|
||||
}),
|
||||
Layer.mock(Credential.Service, {}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const mcp = Layer.mock(MCP.Service, {
|
||||
tools: () =>
|
||||
Effect.succeed([
|
||||
|
|
@ -242,6 +381,163 @@ test("applies the configured MCP execution timeout to prompts", async () => {
|
|||
await expect(result).rejects.toThrow("Request timed out")
|
||||
})
|
||||
|
||||
test("applies configured MCP timeouts to resource operations", async () => {
|
||||
const catalog = Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* MCPClient.connect(
|
||||
"resource-catalog-timeout",
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
|
||||
environment: { MCP_TIMEOUT_TARGET: "resource-catalog" },
|
||||
timeout: new ConfigMCP.Timeout({ catalog: 10 }),
|
||||
}),
|
||||
import.meta.dir,
|
||||
)
|
||||
return yield* connection.resources()
|
||||
}),
|
||||
),
|
||||
)
|
||||
await expect(catalog).rejects.toThrow("Request timed out")
|
||||
|
||||
const read = Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* MCPClient.connect(
|
||||
"resource-read-timeout",
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
|
||||
timeout: new ConfigMCP.Timeout({ execution: 10 }),
|
||||
}),
|
||||
import.meta.dir,
|
||||
)
|
||||
return yield* connection.readResource({ uri: "test://slow" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
await expect(read).rejects.toThrow("Request timed out")
|
||||
})
|
||||
|
||||
test("lists, reads, and reports MCP resource changes", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const server = yield* resourceServer({ listChanged: true })
|
||||
server.state.resourcePages = {
|
||||
initial: {
|
||||
items: [{ name: "Readme", uri: "docs://readme", description: "Project docs" }],
|
||||
nextCursor: "resources-2",
|
||||
},
|
||||
"resources-2": { items: [{ name: "Logo", uri: "docs://logo", mimeType: "image/png" }] },
|
||||
}
|
||||
server.state.templatePages = {
|
||||
initial: {
|
||||
items: [{ name: "File", uriTemplate: "docs://{path}" }],
|
||||
nextCursor: "templates-2",
|
||||
},
|
||||
"templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue" }] },
|
||||
}
|
||||
const connection = yield* MCPClient.connect(
|
||||
"resources",
|
||||
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
|
||||
import.meta.dir,
|
||||
)
|
||||
|
||||
expect(yield* connection.resources()).toEqual([
|
||||
{ name: "Readme", uri: "docs://readme", description: "Project docs", mimeType: undefined },
|
||||
{ name: "Logo", uri: "docs://logo", description: undefined, mimeType: "image/png" },
|
||||
])
|
||||
expect(yield* connection.resourceTemplates()).toEqual([
|
||||
{ name: "File", uriTemplate: "docs://{path}", description: undefined, mimeType: undefined },
|
||||
{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue", mimeType: undefined },
|
||||
])
|
||||
expect(yield* connection.readResource({ uri: "docs://readme" })).toEqual({
|
||||
contents: [
|
||||
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
|
||||
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
|
||||
],
|
||||
})
|
||||
|
||||
const changed = yield* Deferred.make<void>()
|
||||
connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void))
|
||||
yield* Effect.promise(server.sendResourceListChanged)
|
||||
yield* Deferred.await(changed)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("skips MCP resource requests when the capability is absent", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const server = yield* resourceServer({ resources: false })
|
||||
const connection = yield* MCPClient.connect(
|
||||
"resources",
|
||||
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
|
||||
import.meta.dir,
|
||||
)
|
||||
expect(yield* connection.resources()).toEqual([])
|
||||
expect(yield* connection.resourceTemplates()).toEqual([])
|
||||
expect(yield* connection.readResource({ uri: "docs://readme" })).toBeUndefined()
|
||||
expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({
|
||||
resources: 0,
|
||||
templates: 0,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("loads and reads MCP resources", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const server = yield* resourceServer()
|
||||
server.state.resources = [{ name: "Readme", uri: "docs://readme" }]
|
||||
server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
expect(yield* service.resourceCatalog()).toEqual({
|
||||
resources: [
|
||||
{
|
||||
server: "resources",
|
||||
name: "Readme",
|
||||
uri: "docs://readme",
|
||||
description: undefined,
|
||||
mimeType: undefined,
|
||||
},
|
||||
],
|
||||
templates: [
|
||||
{
|
||||
server: "resources",
|
||||
name: "File",
|
||||
uriTemplate: "docs://{path}",
|
||||
description: undefined,
|
||||
mimeType: undefined,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
server.state.resources = [{ name: "Guide", uri: "docs://guide" }]
|
||||
expect((yield* service.resourceCatalog()).resources.map((resource) => resource.uri)).toEqual(["docs://guide"])
|
||||
expect(yield* service.readResource({ server: "resources", uri: "docs://readme" })).toEqual({
|
||||
server: "resources",
|
||||
uri: "docs://readme",
|
||||
contents: [
|
||||
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
|
||||
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
|
||||
],
|
||||
})
|
||||
}).pipe(Effect.provide(resourceMcpLayer(server.url)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
|
|
|||
47
packages/core/test/plugin-hooks.test.ts
Normal file
47
packages/core/test/plugin-hooks.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from "bun:test"
|
||||
import { Message, SystemPart } from "@opencode-ai/llm"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { PluginHooks } from "../src/plugin/hooks"
|
||||
|
||||
describe("PluginHooks", () => {
|
||||
it("registers scoped domain hooks and triggers them sequentially", async () => {
|
||||
const seen: string[] = []
|
||||
const program = Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "request", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push("first")
|
||||
event.system.push(SystemPart.make("second"))
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "request", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(event.system[1]?.text ?? "missing")
|
||||
event.messages = [Message.user("changed")]
|
||||
}),
|
||||
)
|
||||
const event = {
|
||||
sessionID: Session.ID.make("ses_hooks"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
system: [SystemPart.make("first")],
|
||||
messages: [Message.user("original")],
|
||||
tools: {},
|
||||
}
|
||||
|
||||
expect(yield* hooks.trigger("session", "request", event)).toBe(event)
|
||||
expect(seen).toEqual(["first", "second"])
|
||||
expect(event.messages).toEqual([Message.user("changed")])
|
||||
})
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(program).pipe(
|
||||
Effect.provide(PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service>),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue