refactor(tool): destroy Truncate facade, effectify Tool.define (#22093)
This commit is contained in:
@@ -70,7 +70,12 @@ export namespace Tool {
|
|||||||
? Def<P, M>
|
? Def<P, M>
|
||||||
: never
|
: never
|
||||||
|
|
||||||
function wrap<Parameters extends z.ZodType, Result extends Metadata>(id: string, init: Init<Parameters, Result>) {
|
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||||
|
id: string,
|
||||||
|
init: Init<Parameters, Result>,
|
||||||
|
truncate: Truncate.Interface,
|
||||||
|
agents: Agent.Interface,
|
||||||
|
) {
|
||||||
return () =>
|
return () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init }
|
const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init }
|
||||||
@@ -93,8 +98,8 @@ export namespace Tool {
|
|||||||
if (result.metadata.truncated !== undefined) {
|
if (result.metadata.truncated !== undefined) {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
const agent = yield* Effect.promise(() => Agent.get(ctx.agent))
|
const agent = yield* agents.get(ctx.agent)
|
||||||
const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent))
|
const truncated = yield* truncate.output(result.output, {}, agent)
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
output: truncated.content,
|
output: truncated.content,
|
||||||
@@ -112,9 +117,14 @@ export namespace Tool {
|
|||||||
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
||||||
id: ID,
|
id: ID,
|
||||||
init: Effect.Effect<Init<Parameters, Result>, never, R>,
|
init: Effect.Effect<Init<Parameters, Result>, never, R>,
|
||||||
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {
|
): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
|
||||||
return Object.assign(
|
return Object.assign(
|
||||||
Effect.map(init, (init) => ({ id, init: wrap(id, init) })),
|
Effect.gen(function* () {
|
||||||
|
const resolved = yield* init
|
||||||
|
const truncate = yield* Truncate.Service
|
||||||
|
const agents = yield* Agent.Service
|
||||||
|
return { id, init: wrap(id, resolved, truncate, agents) }
|
||||||
|
}),
|
||||||
{ id },
|
{ id },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { NodePath } from "@effect/platform-node"
|
|||||||
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
|
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import type { Agent } from "../agent/agent"
|
import type { Agent } from "../agent/agent"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { evaluate } from "@/permission/evaluate"
|
import { evaluate } from "@/permission/evaluate"
|
||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
@@ -135,10 +134,4 @@ export namespace Truncate {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
|
|
||||||
return runPromise((s) => s.output(text, options, agent))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { LSP } from "../../src/lsp"
|
import { LSP } from "../../src/lsp"
|
||||||
import { AppFileSystem } from "../../src/filesystem"
|
import { AppFileSystem } from "../../src/filesystem"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
|
||||||
const runtime = ManagedRuntime.make(
|
const runtime = ManagedRuntime.make(
|
||||||
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
|
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer, Truncate.defaultLayer, Agent.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
const baseCtx = {
|
const baseCtx = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import type { Permission } from "../../src/permission"
|
import type { Permission } from "../../src/permission"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Truncate } from "../../src/tool/truncate"
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
@@ -15,7 +16,13 @@ import { AppFileSystem } from "../../src/filesystem"
|
|||||||
import { Plugin } from "../../src/plugin"
|
import { Plugin } from "../../src/plugin"
|
||||||
|
|
||||||
const runtime = ManagedRuntime.make(
|
const runtime = ManagedRuntime.make(
|
||||||
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, Plugin.defaultLayer),
|
Layer.mergeAll(
|
||||||
|
CrossSpawnSpawner.defaultLayer,
|
||||||
|
AppFileSystem.defaultLayer,
|
||||||
|
Plugin.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
|
Agent.defaultLayer,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
function initBash() {
|
function initBash() {
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { FileTime } from "../../src/file/time"
|
|||||||
import { LSP } from "../../src/lsp"
|
import { LSP } from "../../src/lsp"
|
||||||
import { AppFileSystem } from "../../src/filesystem"
|
import { AppFileSystem } from "../../src/filesystem"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { BusEvent } from "../../src/bus/bus-event"
|
import { BusEvent } from "../../src/bus/bus-event"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -34,7 +36,7 @@ async function touch(file: string, time: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runtime = ManagedRuntime.make(
|
const runtime = ManagedRuntime.make(
|
||||||
Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
|
Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer, Truncate.defaultLayer, Agent.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
|
||||||
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
|
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer))
|
||||||
|
|
||||||
function initGrep() {
|
function initGrep() {
|
||||||
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init())))
|
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init())))
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { Tool } from "../../src/tool/tool"
|
|||||||
import { QuestionTool } from "../../src/tool/question"
|
import { QuestionTool } from "../../src/tool/question"
|
||||||
import { Question } from "../../src/question"
|
import { Question } from "../../src/question"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@ const ctx = {
|
|||||||
ask: () => Effect.void,
|
ask: () => Effect.void,
|
||||||
}
|
}
|
||||||
|
|
||||||
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer))
|
||||||
|
|
||||||
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
|
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import { Instruction } from "../../src/session/instruction"
|
import { Instruction } from "../../src/session/instruction"
|
||||||
import { ReadTool } from "../../src/tool/read"
|
import { ReadTool } from "../../src/tool/read"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { Tool } from "../../src/tool/tool"
|
import { Tool } from "../../src/tool/tool"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
@@ -41,6 +42,7 @@ const it = testEffect(
|
|||||||
FileTime.defaultLayer,
|
FileTime.defaultLayer,
|
||||||
Instruction.defaultLayer,
|
Instruction.defaultLayer,
|
||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Skill } from "../../src/skill"
|
import { Skill } from "../../src/skill"
|
||||||
import { Ripgrep } from "../../src/file/ripgrep"
|
import { Ripgrep } from "../../src/file/ripgrep"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
@@ -150,7 +152,7 @@ Use this skill.
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer))
|
const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer))
|
||||||
const info = await runtime.runPromise(SkillTool)
|
const info = await runtime.runPromise(SkillTool)
|
||||||
const tool = await runtime.runPromise(info.init())
|
const tool = await runtime.runPromise(info.init())
|
||||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { SessionPrompt } from "../../src/session/prompt"
|
|||||||
import { MessageID, PartID } from "../../src/session/schema"
|
import { MessageID, PartID } from "../../src/session/schema"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
|
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { ToolRegistry } from "../../src/tool/registry"
|
import { ToolRegistry } from "../../src/tool/registry"
|
||||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
@@ -29,6 +30,7 @@ const it = testEffect(
|
|||||||
Config.defaultLayer,
|
Config.defaultLayer,
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
Session.defaultLayer,
|
Session.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
ToolRegistry.defaultLayer,
|
ToolRegistry.defaultLayer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Tool } from "../../src/tool/tool"
|
import { Tool } from "../../src/tool/tool"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
|
|
||||||
|
const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
|
||||||
|
|
||||||
const params = z.object({ input: z.string() })
|
const params = z.object({ input: z.string() })
|
||||||
|
|
||||||
@@ -21,7 +25,7 @@ describe("Tool.define", () => {
|
|||||||
const original = makeTool("test")
|
const original = makeTool("test")
|
||||||
const originalExecute = original.execute
|
const originalExecute = original.execute
|
||||||
|
|
||||||
const info = await Effect.runPromise(Tool.define("test-tool", Effect.succeed(original)))
|
const info = await runtime.runPromise(Tool.define("test-tool", Effect.succeed(original)))
|
||||||
|
|
||||||
await Effect.runPromise(info.init())
|
await Effect.runPromise(info.init())
|
||||||
await Effect.runPromise(info.init())
|
await Effect.runPromise(info.init())
|
||||||
@@ -31,7 +35,7 @@ describe("Tool.define", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("effect-defined tool returns fresh objects and is unaffected", async () => {
|
test("effect-defined tool returns fresh objects and is unaffected", async () => {
|
||||||
const info = await Effect.runPromise(
|
const info = await runtime.runPromise(
|
||||||
Tool.define(
|
Tool.define(
|
||||||
"test-fn-tool",
|
"test-fn-tool",
|
||||||
Effect.succeed(() => Effect.succeed(makeTool("test"))),
|
Effect.succeed(() => Effect.succeed(makeTool("test"))),
|
||||||
@@ -45,7 +49,7 @@ describe("Tool.define", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("object-defined tool returns distinct objects per init() call", async () => {
|
test("object-defined tool returns distinct objects per init() call", async () => {
|
||||||
const info = await Effect.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
|
const info = await runtime.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
|
||||||
|
|
||||||
const first = await Effect.runPromise(info.init())
|
const first = await Effect.runPromise(info.init())
|
||||||
const second = await Effect.runPromise(info.init())
|
const second = await Effect.runPromise(info.init())
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, expect } from "bun:test"
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Effect, FileSystem, Layer } from "effect"
|
import { Effect, FileSystem, Layer } from "effect"
|
||||||
import { Truncate, Truncate as TruncateSvc } from "../../src/tool/truncate"
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { Identifier } from "../../src/id/id"
|
import { Identifier } from "../../src/id/id"
|
||||||
import { Process } from "../../src/util/process"
|
import { Process } from "../../src/util/process"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
@@ -12,120 +12,155 @@ import { writeFileStringScoped } from "../lib/filesystem"
|
|||||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||||
const ROOT = path.resolve(import.meta.dir, "..", "..")
|
const ROOT = path.resolve(import.meta.dir, "..", "..")
|
||||||
|
|
||||||
|
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer))
|
||||||
|
|
||||||
describe("Truncate", () => {
|
describe("Truncate", () => {
|
||||||
describe("output", () => {
|
describe("output", () => {
|
||||||
test("truncates large json file by bytes", async () => {
|
it.live("truncates large json file by bytes", () =>
|
||||||
const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(content)
|
const svc = yield* Truncate.Service
|
||||||
|
const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
|
||||||
|
const result = yield* svc.output(content)
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("truncated...")
|
expect(result.content).toContain("truncated...")
|
||||||
if (result.truncated) expect(result.outputPath).toBeDefined()
|
if (result.truncated) expect(result.outputPath).toBeDefined()
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("returns content unchanged when under limits", async () => {
|
it.live("returns content unchanged when under limits", () =>
|
||||||
const content = "line1\nline2\nline3"
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(content)
|
const svc = yield* Truncate.Service
|
||||||
|
const content = "line1\nline2\nline3"
|
||||||
|
const result = yield* svc.output(content)
|
||||||
|
|
||||||
expect(result.truncated).toBe(false)
|
expect(result.truncated).toBe(false)
|
||||||
expect(result.content).toBe(content)
|
expect(result.content).toBe(content)
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("truncates by line count", async () => {
|
it.live("truncates by line count", () =>
|
||||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(lines, { maxLines: 10 })
|
const svc = yield* Truncate.Service
|
||||||
|
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||||
|
const result = yield* svc.output(lines, { maxLines: 10 })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("...90 lines truncated...")
|
expect(result.content).toContain("...90 lines truncated...")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("truncates by byte count", async () => {
|
it.live("truncates by byte count", () =>
|
||||||
const content = "a".repeat(1000)
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(content, { maxBytes: 100 })
|
const svc = yield* Truncate.Service
|
||||||
|
const content = "a".repeat(1000)
|
||||||
|
const result = yield* svc.output(content, { maxBytes: 100 })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("truncated...")
|
expect(result.content).toContain("truncated...")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("truncates from head by default", async () => {
|
it.live("truncates from head by default", () =>
|
||||||
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(lines, { maxLines: 3 })
|
const svc = yield* Truncate.Service
|
||||||
|
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
||||||
|
const result = yield* svc.output(lines, { maxLines: 3 })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("line0")
|
expect(result.content).toContain("line0")
|
||||||
expect(result.content).toContain("line1")
|
expect(result.content).toContain("line1")
|
||||||
expect(result.content).toContain("line2")
|
expect(result.content).toContain("line2")
|
||||||
expect(result.content).not.toContain("line9")
|
expect(result.content).not.toContain("line9")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("truncates from tail when direction is tail", async () => {
|
it.live("truncates from tail when direction is tail", () =>
|
||||||
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" })
|
const svc = yield* Truncate.Service
|
||||||
|
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
||||||
|
const result = yield* svc.output(lines, { maxLines: 3, direction: "tail" })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("line7")
|
expect(result.content).toContain("line7")
|
||||||
expect(result.content).toContain("line8")
|
expect(result.content).toContain("line8")
|
||||||
expect(result.content).toContain("line9")
|
expect(result.content).toContain("line9")
|
||||||
expect(result.content).not.toContain("line0")
|
expect(result.content).not.toContain("line0")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("uses default MAX_LINES and MAX_BYTES", () => {
|
test("uses default MAX_LINES and MAX_BYTES", () => {
|
||||||
expect(Truncate.MAX_LINES).toBe(2000)
|
expect(Truncate.MAX_LINES).toBe(2000)
|
||||||
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
|
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("large single-line file truncates with byte message", async () => {
|
it.live("large single-line file truncates with byte message", () =>
|
||||||
const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(content)
|
const svc = yield* Truncate.Service
|
||||||
|
const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
|
||||||
|
const result = yield* svc.output(content)
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("bytes truncated...")
|
expect(result.content).toContain("bytes truncated...")
|
||||||
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
|
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("writes full output to file when truncated", async () => {
|
it.live("writes full output to file when truncated", () =>
|
||||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(lines, { maxLines: 10 })
|
const svc = yield* Truncate.Service
|
||||||
|
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||||
|
const result = yield* svc.output(lines, { maxLines: 10 })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("The tool call succeeded but the output was truncated")
|
expect(result.content).toContain("The tool call succeeded but the output was truncated")
|
||||||
expect(result.content).toContain("Grep")
|
expect(result.content).toContain("Grep")
|
||||||
if (!result.truncated) throw new Error("expected truncated")
|
if (!result.truncated) throw new Error("expected truncated")
|
||||||
expect(result.outputPath).toBeDefined()
|
expect(result.outputPath).toBeDefined()
|
||||||
expect(result.outputPath).toContain("tool_")
|
expect(result.outputPath).toContain("tool_")
|
||||||
|
|
||||||
const written = await Filesystem.readText(result.outputPath!)
|
const written = yield* Effect.promise(() => Filesystem.readText(result.outputPath!))
|
||||||
expect(written).toBe(lines)
|
expect(written).toBe(lines)
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("suggests Task tool when agent has task permission", async () => {
|
it.live("suggests Task tool when agent has task permission", () =>
|
||||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
Effect.gen(function* () {
|
||||||
const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
|
const svc = yield* Truncate.Service
|
||||||
const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
|
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||||
|
const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
|
||||||
|
const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("Grep")
|
expect(result.content).toContain("Grep")
|
||||||
expect(result.content).toContain("Task tool")
|
expect(result.content).toContain("Task tool")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("omits Task tool hint when agent lacks task permission", async () => {
|
it.live("omits Task tool hint when agent lacks task permission", () =>
|
||||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
Effect.gen(function* () {
|
||||||
const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
|
const svc = yield* Truncate.Service
|
||||||
const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
|
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||||
|
const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
|
||||||
|
const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("Grep")
|
expect(result.content).toContain("Grep")
|
||||||
expect(result.content).not.toContain("Task tool")
|
expect(result.content).not.toContain("Task tool")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("does not write file when not truncated", async () => {
|
it.live("does not write file when not truncated", () =>
|
||||||
const content = "short content"
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(content)
|
const svc = yield* Truncate.Service
|
||||||
|
const content = "short content"
|
||||||
|
const result = yield* svc.output(content)
|
||||||
|
|
||||||
expect(result.truncated).toBe(false)
|
expect(result.truncated).toBe(false)
|
||||||
if (result.truncated) throw new Error("expected not truncated")
|
if (result.truncated) throw new Error("expected not truncated")
|
||||||
expect("outputPath" in result).toBe(false)
|
expect("outputPath" in result).toBe(false)
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("loads truncate effect in a fresh process", async () => {
|
test("loads truncate effect in a fresh process", async () => {
|
||||||
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
|
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
|
||||||
@@ -138,10 +173,10 @@ describe("Truncate", () => {
|
|||||||
|
|
||||||
describe("cleanup", () => {
|
describe("cleanup", () => {
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
|
|
||||||
|
|
||||||
it.live("deletes files older than 7 days and preserves recent files", () =>
|
it.live("deletes files older than 7 days and preserves recent files", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Truncate.Service
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
|
||||||
yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
|
yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
|
||||||
@@ -151,7 +186,7 @@ describe("Truncate", () => {
|
|||||||
|
|
||||||
yield* writeFileStringScoped(old, "old content")
|
yield* writeFileStringScoped(old, "old content")
|
||||||
yield* writeFileStringScoped(recent, "recent content")
|
yield* writeFileStringScoped(recent, "recent content")
|
||||||
yield* TruncateSvc.Service.use((s) => s.cleanup())
|
yield* svc.cleanup()
|
||||||
|
|
||||||
expect(yield* fs.exists(old)).toBe(false)
|
expect(yield* fs.exists(old)).toBe(false)
|
||||||
expect(yield* fs.exists(recent)).toBe(true)
|
expect(yield* fs.exists(recent)).toBe(true)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { WebFetchTool } from "../../src/tool/webfetch"
|
import { WebFetchTool } from "../../src/tool/webfetch"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
@@ -24,10 +26,11 @@ async function withFetch(fetch: (req: Request) => Response | Promise<Response>,
|
|||||||
await fn(server.url)
|
await fn(server.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTool() {
|
function exec(args: { url: string; format: "text" | "markdown" | "html" }) {
|
||||||
return WebFetchTool.pipe(
|
return WebFetchTool.pipe(
|
||||||
Effect.flatMap((info) => info.init()),
|
Effect.flatMap((info) => info.init()),
|
||||||
Effect.provide(FetchHttpClient.layer),
|
Effect.flatMap((tool) => tool.execute(args, ctx)),
|
||||||
|
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)),
|
||||||
Effect.runPromise,
|
Effect.runPromise,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -41,10 +44,7 @@ describe("tool.webfetch", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectRoot,
|
directory: projectRoot,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const webfetch = await initTool()
|
const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
|
||||||
const result = await Effect.runPromise(
|
|
||||||
webfetch.execute({ url: new URL("/image.png", url).toString(), format: "markdown" }, ctx),
|
|
||||||
)
|
|
||||||
expect(result.output).toBe("Image fetched successfully")
|
expect(result.output).toBe("Image fetched successfully")
|
||||||
expect(result.attachments).toBeDefined()
|
expect(result.attachments).toBeDefined()
|
||||||
expect(result.attachments?.length).toBe(1)
|
expect(result.attachments?.length).toBe(1)
|
||||||
@@ -72,10 +72,7 @@ describe("tool.webfetch", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectRoot,
|
directory: projectRoot,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const webfetch = await initTool()
|
const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" })
|
||||||
const result = await Effect.runPromise(
|
|
||||||
webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx),
|
|
||||||
)
|
|
||||||
expect(result.output).toContain("<svg")
|
expect(result.output).toContain("<svg")
|
||||||
expect(result.attachments).toBeUndefined()
|
expect(result.attachments).toBeUndefined()
|
||||||
},
|
},
|
||||||
@@ -95,10 +92,7 @@ describe("tool.webfetch", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectRoot,
|
directory: projectRoot,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const webfetch = await initTool()
|
const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" })
|
||||||
const result = await Effect.runPromise(
|
|
||||||
webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx),
|
|
||||||
)
|
|
||||||
expect(result.output).toBe("hello from webfetch")
|
expect(result.output).toBe("hello from webfetch")
|
||||||
expect(result.attachments).toBeUndefined()
|
expect(result.attachments).toBeUndefined()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { AppFileSystem } from "../../src/filesystem"
|
|||||||
import { FileTime } from "../../src/file/time"
|
import { FileTime } from "../../src/file/time"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { Tool } from "../../src/tool/tool"
|
import { Tool } from "../../src/tool/tool"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
@@ -38,6 +40,8 @@ const it = testEffect(
|
|||||||
Bus.layer,
|
Bus.layer,
|
||||||
Format.defaultLayer,
|
Format.defaultLayer,
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
|
Agent.defaultLayer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user