Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
- The format is based on [Keep a Changelog](https://keepachangelog.com/).
- This project adheres to [Semantic Versioning](https://semver.org/).

## Version 0.9.2 - TBD

### Fixed

- Fixed skill loading for markdown-based agents

## Version 0.9.1 - 2026-08-14

### Added
Expand Down
28 changes: 16 additions & 12 deletions lib/agents/markdown/backends/outputs-backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*
*/

import { getConfig } from "@langchain/langgraph"
import { isTextMime, globToRegex } from "./mime-utils.js"

function inferMimeType(name) {
Expand Down Expand Up @@ -38,11 +39,14 @@ function inferMimeType(name) {
}

export class OutputsBackend {
constructor(taskId, fileStore) {
this.taskId = taskId
constructor(fileStore) {
this.fileStore = fileStore
}

_taskId() {
return getConfig()?.configurable?._taskId || ""
}

// CompositeBackend strips the route prefix (/outputs/) before delegating,
// so paths arrive here as either "/name" (stripped) or "/outputs/name" (direct).
// _name() handles both forms.
Expand All @@ -54,19 +58,19 @@ export class OutputsBackend {
const name = this._name(filePath)
const mimeType = options.mimeType || inferMimeType(name)
const buf = typeof content === "string" ? Buffer.from(content, "utf-8") : Buffer.from(content)
await this.fileStore.saveOutputFile(this.taskId, name, mimeType, buf)
await this.fileStore.saveOutputFile(this._taskId(), name, mimeType, buf)
return { success: true }
}

async exists(filePath) {
const name = this._name(filePath)
const files = await this.fileStore.listOutputFilesMeta(this.taskId)
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
return files.some((f) => f.name === name)
}

async stat(filePath) {
const name = this._name(filePath)
const files = await this.fileStore.listOutputFilesMeta(this.taskId)
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
const file = files.find((f) => f.name === name)
if (!file) return null
return { name: file.name, mimeType: file.mimeType, size: file.size }
Expand All @@ -75,12 +79,12 @@ export class OutputsBackend {
// CompositeBackend re-prepends the route prefix to paths returned here,
// so return bare "/<name>" paths (without /outputs/) to avoid double-prefix.
async list(_dirPath) {
const files = await this.fileStore.listOutputFilesMeta(this.taskId)
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
return files.map((f) => `/${f.name}`)
}

async ls(_path) {
const files = await this.fileStore.listOutputFilesMeta(this.taskId)
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
return {
files: files.map((f) => ({
path: `/${f.name}`,
Expand All @@ -93,7 +97,7 @@ export class OutputsBackend {

async glob(pattern, _path) {
const re = globToRegex(pattern)
const files = await this.fileStore.listOutputFilesMeta(this.taskId)
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
return {
files: files
.filter((f) => re.test(f.name))
Expand All @@ -103,7 +107,7 @@ export class OutputsBackend {

async grep(pattern, _path, glob) {
const reGlob = glob ? globToRegex(glob) : null
const files = await this.fileStore.listOutputFiles(this.taskId)
const files = await this.fileStore.listOutputFiles(this._taskId())
const matches = []
for (const f of files) {
if (reGlob && !reGlob.test(f.name)) continue
Expand All @@ -120,9 +124,9 @@ export class OutputsBackend {

async read(filePath, offset = 0, limit = 100) {
const name = this._name(filePath)
const file = await this.fileStore.getOutputFile(this.taskId, name)
const file = await this.fileStore.getOutputFile(this._taskId(), name)
if (!file) {
const files = await this.fileStore.listOutputFilesMeta(this.taskId)
const files = await this.fileStore.listOutputFilesMeta(this._taskId())
const available = files.map((f) => `/outputs/${f.name}`).join(", ") || "none"
return { error: `File not found: ${filePath}. Available: ${available}` }
}
Expand All @@ -141,7 +145,7 @@ export class OutputsBackend {
// for the rationale. Currently unused by our shipped code paths; do not delete.
async readRaw(filePath) {
const name = this._name(filePath)
const file = await this.fileStore.getOutputFile(this.taskId, name)
const file = await this.fileStore.getOutputFile(this._taskId(), name)
if (!file) return { error: `File not found: ${filePath}` }
return { bytes: file.bytes, mimeType: file.mimeType, size: file.size }
}
Expand Down
48 changes: 48 additions & 0 deletions lib/agents/markdown/backends/readonly-backend.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { FilesystemBackend } from "deepagents"

export class ReadonlyBackend {
constructor(options) {
// explicitly keep this internal (no class inheritance) so we do not expose write operations
this._fsBackend = new FilesystemBackend(options)
}

ls(path) {
return this._fsBackend.ls(path)
}

read(filePath, offset, limit) {
return this._fsBackend.read(filePath, offset, limit)
}

readRaw(filePath) {
return this._fsBackend.readRaw(filePath)
}

grep(pattern, path, glob, maxCount) {
return this._fsBackend.grep(pattern, path, glob, maxCount)
}

glob(pattern, path) {
return this._fsBackend.glob(pattern, path)
}

downloadFiles(paths) {
return this._fsBackend.downloadFiles(paths)
}

write() {
return { error: "read-only" }
}

edit() {
return { error: "read-only" }
}

delete() {
return { error: "read-only" }
}

uploadFiles() {
return { error: "read-only" }
}
}
43 changes: 30 additions & 13 deletions lib/agents/markdown/backends/uploads-backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,22 @@
*
*/

import { getConfig } from "@langchain/langgraph"
import { isTextMime, globToRegex } from "./mime-utils.js"

export class UploadsBackend {
constructor(contextId, fileStore, userId) {
this.contextId = contextId
constructor(fileStore) {
this.fileStore = fileStore
this.userId = userId
}

_resolveContext() {
const config = getConfig()
const rawThreadId = config?.configurable?.thread_id || ""
const contextId = rawThreadId.includes(":")
? rawThreadId.split(":").slice(1).join(":")
: rawThreadId
const userId = config?.configurable?._userId
return { contextId, userId }
}

// CompositeBackend strips the route prefix (/uploads/) before delegating,
Expand All @@ -26,12 +35,13 @@ export class UploadsBackend {
}

async read(filePath, offset = 0, limit = 100) {
const { contextId, userId } = this._resolveContext()
const name = this._name(filePath)
const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
const file = await this.fileStore.getInputFile(contextId, name, userId)

if (!file) {
const available =
(await this.fileStore.listInputFiles(this.contextId, this.userId))
(await this.fileStore.listInputFiles(contextId, userId))
.map((f) => `/uploads/${f.name}`)
.join(", ") || "none"
return { error: `File not found: ${filePath}. Available: ${available}` }
Expand All @@ -58,21 +68,24 @@ export class UploadsBackend {
// throws `TypeError: backend.readRaw is not a function`. Currently unused
// by our shipped code paths — do not delete as "dead".
async readRaw(filePath) {
const { contextId, userId } = this._resolveContext()
const name = this._name(filePath)
const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
const file = await this.fileStore.getInputFile(contextId, name, userId)
if (!file) return { error: `File not found: ${filePath}` }
return { bytes: file.bytes, mimeType: file.mimeType, size: file.size }
}

// CompositeBackend re-prepends the route prefix to paths returned here,
// so return bare "/<name>" paths (without /uploads/) to avoid double-prefix.
async list(_dirPath) {
const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
const { contextId, userId } = this._resolveContext()
const files = await this.fileStore.listInputFiles(contextId, userId)
return files.map((f) => `/${f.name}`)
}

async ls(_path) {
const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
const { contextId, userId } = this._resolveContext()
const files = await this.fileStore.listInputFiles(contextId, userId)
return {
files: files.map((f) => ({
path: `/${f.name}`,
Expand All @@ -84,8 +97,9 @@ export class UploadsBackend {
}

async glob(pattern, _path) {
const { contextId, userId } = this._resolveContext()
const re = globToRegex(pattern)
const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
const files = await this.fileStore.listInputFiles(contextId, userId)
return {
files: files
.filter((f) => re.test(f.name))
Expand All @@ -94,13 +108,14 @@ export class UploadsBackend {
}

async grep(pattern, _path, glob) {
const { contextId, userId } = this._resolveContext()
const reGlob = glob ? globToRegex(glob) : null
const files = await this.fileStore.listInputFiles(this.contextId, this.userId)
const files = await this.fileStore.listInputFiles(contextId, userId)
const candidates = files.filter(
(f) => (!reGlob || reGlob.test(f.name)) && isTextMime(f.mimeType),
)
const fetched = await Promise.all(
candidates.map((f) => this.fileStore.getInputFile(this.contextId, f.name, this.userId)),
candidates.map((f) => this.fileStore.getInputFile(contextId, f.name, userId)),
)
const matches = []
for (let i = 0; i < candidates.length; i++) {
Expand All @@ -121,14 +136,16 @@ export class UploadsBackend {
}

async exists(filePath) {
const { contextId, userId } = this._resolveContext()
const name = this._name(filePath)
const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
const file = await this.fileStore.getInputFile(contextId, name, userId)
return !!file
}

async stat(filePath) {
const { contextId, userId } = this._resolveContext()
const name = this._name(filePath)
const file = await this.fileStore.getInputFile(this.contextId, name, this.userId)
const file = await this.fileStore.getInputFile(contextId, name, userId)
if (!file) return null
return { name: file.name, mimeType: file.mimeType, size: file.size }
}
Expand Down
41 changes: 15 additions & 26 deletions lib/agents/markdown/deep-agent.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import cds from "@sap/cds"
import { ReadonlyBackend } from "./backends/readonly-backend.js"
const { fs, path } = cds.utils

const LOG = cds.log("agents")
Expand All @@ -22,7 +23,6 @@ export async function createAutoDeepAgent(srv, agentDir) {
createDeepAgent,
StateBackend,
CompositeBackend,
FilesystemBackend,
createMemoryMiddleware,
createSkillsMiddleware,
} = await import("deepagents")
Expand All @@ -33,37 +33,20 @@ export async function createAutoDeepAgent(srv, agentDir) {
tools: tools.length,
})

let backend
const additionalBackends = {}

if (cds.env.agents?.fileIO?.enabled) {
const { CdsFileStore } = await import("../../protocol/persistence/file-store.js")
const { UploadsBackend } = await import("./backends/uploads-backend.js")
const { OutputsBackend } = await import("./backends/outputs-backend.js")
const fileStore = new CdsFileStore()
backend = (runtime) => {
const rawThreadId = runtime?.configurable?.thread_id || ""
const contextId = rawThreadId.includes(":")
? rawThreadId.split(":").slice(1).join(":")
: rawThreadId
const taskId = runtime?.configurable?._taskId || ""
// GraphExecutor threads the request-entry user id as `_userId` so the
// backend keeps user isolation even if cds.context drifts inside the
// agent's tool callbacks. Falls back to cds.context for compatibility.
const userId = runtime?.configurable?._userId
return new CompositeBackend(
new StateBackend(),
{
"/uploads/": new UploadsBackend(contextId, fileStore, userId),
"/outputs/": new OutputsBackend(taskId, fileStore),
},
// Fallback to StateBackend for any other paths so deepagents'
// built-in tools that read state still work.
)
}
} else {
backend = new StateBackend()
Object.assign(additionalBackends, {
"/uploads/": new UploadsBackend(fileStore),
"/outputs/": new OutputsBackend(fileStore),
})
}

const agentFsBackend = new FilesystemBackend({ rootDir: agentDir })
const agentFsBackend = new ReadonlyBackend({ rootDir: agentDir, virtualMode: true })
const agentDirMiddleware = []
if (fs.existsSync(path.join(agentDir, "AGENTS.md"))) {
agentDirMiddleware.push(
Expand All @@ -74,14 +57,20 @@ export async function createAutoDeepAgent(srv, agentDir) {
)
}
if (fs.existsSync(path.join(agentDir, "skills"))) {
additionalBackends["./skills/"] = additionalBackends["/skills/"] = new ReadonlyBackend({
rootDir: path.join(agentDir, "skills"),
virtualMode: true,
})
agentDirMiddleware.push(
createSkillsMiddleware({
backend: agentFsBackend,
sources: ["./skills/"],
sources: ["./skills"],
}),
)
}

const backend = new CompositeBackend(new StateBackend(), additionalBackends)

return createDeepAgent({
model,
tools,
Expand Down
Loading