feat: tool plugin source attribution in the Context browser (#25)
Tool-schema rows in the Context browser carry a best-effort source tag for the registering plugin, recovered without a harness change: - a harness-logged `plugin` field passes through verbatim (future-proof); - MCP-proxied tools resolve `mcp:<server>` from their public names; - shipped first-party tools resolve from a pinned package map; - a live `tools.register()` hook (cordis `internal/get` reader slot, with a caller-package stack fallback for local-link installs) tags third-party, agent-scoped, and dynamic registrations, and peels a prior incarnation's wrapper on reload; - tools already registered when the plugin mounts render a localized "unknown plugin" tag explaining the boot-order gap. Priority: `mcp:<server>` > live record > pinned map > unknown. The chip rides the shared tag frame tinted with the brand badge tokens. Rebased onto v0.35.0 with maintainer review fixes; the attribution priority and reload-safety rework and the chip theming are described in the PR review comment. Co-authored-by: Nwflower <1679659@qq.com> Co-authored-by: bowenliang123 <liang.bowen.123@qq.com>
This commit is contained in:
22 files changed
+974
-9
No files matched your search
@@ -119,6 +119,8 @@ Pick **Live (next request)** or any retained step from the picker, and browse wh
|
||||
|
||||
Six collapsible category sections (system prompt, tool schemas, user messages, injected context, assistant replies, tool results) expand into one row per element — each with its token price — and every element expands again into its **actual content**: the full system prompt, each tool's description and JSON schema, message text, reasoning, tool-call arguments, and tool outputs.
|
||||
|
||||
- **Who provides each tool** — tool-schema rows carry a best-effort source tag when attribution is knowable: the pinned first-party package map (`@deepseek-ai/dsh-tool-*`) for shipped tools, `mcp:<server>` for MCP-proxied tools (their public names encode the server), or a harness-logged `plugin` field. Beyond those, the host also watches live `tools.register()` calls and tags each tool to the plugin that registered it — third-party, agent-scoped and dynamically named tools included, even plugins installed from a local path or npm/pnpm link (their package name is resolved from the registering code's directory). The session log itself records only each tool's name/description/parameters, so a tool that was **already registered when the context plugin mounted** (e.g. local-link plugins that load earlier in the bundle) shows an **Unknown plugin** tag instead: the registering plugin is gone from every observable surface by the time attribution starts. Tools not in the current registry at all (older sessions) stay untagged.
|
||||
|
||||
- **Linked with the trend chart** — hover any bar in the Context trend card and the browser previews that step instantly; leave the chart and it returns to your own pick. Keep a category open while scrubbing to compare one category across steps. Clicking a step-brief row (**User** / **In** / **Response**) opens that exact message here, expanded and scrolled into view.
|
||||
- **Honest about coverage** — steps before a compaction are reconstructed from the removed-message archive, and the card says so when a step's makeup is only approximate. Elements older than the loaded chat window page older history in automatically when you expand them, and live injections (AGENTS.md, session-start context, …) are always listed — never a token sum without its items.
|
||||
- **Diff against the previous turn** — switch the picker to **vs previous turn** and every category gets signed delta badges (`+N` items, `+Nk` tokens), so one glance tells you what the conversation added since the end of the last turn.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type * as ReactNS from 'react'
|
||||
import type { Category, ContextHeaders, ContextTimeline, HeaderTool, RequestRecord, SurfaceNode } from '../../shared/types'
|
||||
import { UNKNOWN_TOOL_SOURCE, type Category, type ContextHeaders, type ContextTimeline, type HeaderTool, type RequestRecord, type SurfaceNode } from '../../shared/types'
|
||||
import { assemble } from '../assemble'
|
||||
import type { Assembled } from '../assemble'
|
||||
import { CATS, partsOf } from '../categories'
|
||||
@@ -658,9 +658,9 @@ export function makeContextBrowser(
|
||||
* result scans while collapsed.
|
||||
*/
|
||||
const elemRow = (
|
||||
key: string, tag: string | null, preview: string,
|
||||
key: string, tag: ReactNS.ReactNode | null, preview: string,
|
||||
tokens: number, time: number | undefined, body: ReactNS.ReactNode,
|
||||
err = false,
|
||||
err = false, trailing: ReactNS.ReactNode = null,
|
||||
) => {
|
||||
const open = openElem === key
|
||||
return (
|
||||
@@ -670,6 +670,7 @@ export function makeContextBrowser(
|
||||
{err ? <span className="lc-br-err-dot" title={t('node.failed')} /> : null}
|
||||
{tag !== null ? <span className="lc-br-tag">{tag}</span> : null}
|
||||
<span className="lc-br-preview">{preview}</span>
|
||||
{trailing !== null ? trailing : null}
|
||||
{time !== undefined ? <span className="lc-br-time">{fmtTime(time)}</span> : null}
|
||||
<span className="lc-br-tokens">{'≈' + fmt(tokens)}</span>
|
||||
</button>
|
||||
@@ -699,8 +700,20 @@ export function makeContextBrowser(
|
||||
hide: t('tool.jsonHide'),
|
||||
}
|
||||
return view.header.tools.slice().sort((a, b) => b.tokens - a.tokens).map((tool: HeaderTool) => {
|
||||
// The registering plugin (best-effort host attribution — see
|
||||
// toolSources.ts) trails the tool name as a standalone chip, so it
|
||||
// needs no extra frame around it. A tool whose provider predates
|
||||
// the attribution hook arrives with the UNKNOWN_TOOL_SOURCE
|
||||
// sentinel and renders a localized "unknown plugin" tag whose
|
||||
// tooltip explains why no provider is shown.
|
||||
const trailing = tool.plugin !== undefined
|
||||
? <span className="lc-br-tag lc-br-tool-plugin" title={tool.plugin === UNKNOWN_TOOL_SOURCE ? t('tool.unknownTitle') : t('tool.plugin')}>
|
||||
{tool.plugin === UNKNOWN_TOOL_SOURCE ? t('tool.unknown') : tool.plugin}
|
||||
</span>
|
||||
: null
|
||||
return elemRow('tool:' + tool.name, null, tool.name, tool.tokens, undefined,
|
||||
<ToolSchema description={tool.description} schema={tool.schema} rich={rich} lines={lineLabel} labels={labels} />)
|
||||
<ToolSchema description={tool.description} schema={tool.schema} rich={rich} lines={lineLabel} labels={labels} />,
|
||||
false, trailing)
|
||||
})
|
||||
}
|
||||
// List surface nodes newest first (the live surface's reading order).
|
||||
|
||||
@@ -157,6 +157,9 @@ export const DICT_ZH: Record<string, string> = {
|
||||
'tool.desc': '描述',
|
||||
'tool.params': '参数',
|
||||
'tool.paramsEmpty': '(无参数)',
|
||||
'tool.plugin': '该工具的注册插件(仅供参考)',
|
||||
'tool.unknown': '未知插件',
|
||||
'tool.unknownTitle': '因早于上下文插件加载或原插件已卸载,无法查询该工具的提供者',
|
||||
'tool.jsonToggle': '查看原始 JSON',
|
||||
'tool.jsonHide': '收起',
|
||||
'rich.raw': '原文',
|
||||
@@ -337,6 +340,9 @@ export const DICT_EN: Record<string, string> = {
|
||||
'tool.desc': 'Description',
|
||||
'tool.params': 'Parameters',
|
||||
'tool.paramsEmpty': '(no parameters)',
|
||||
'tool.plugin': 'The registering plugin of this tool (for reference)',
|
||||
'tool.unknown': 'Unknown plugin',
|
||||
'tool.unknownTitle': 'The tool was registered before the context plugin loaded, or its plugin has been unloaded, so its provider cannot be determined',
|
||||
'tool.jsonToggle': 'View Raw JSON',
|
||||
'tool.jsonHide': 'Collapse',
|
||||
'rich.raw': 'Raw',
|
||||
|
||||
@@ -358,6 +358,11 @@ button.lc-brief-row-link { cursor: pointer; }
|
||||
.lc-br-err-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-state-error-primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); }
|
||||
/* Shrinkable under width pressure (min-width: 0) so a long tool/call tag ellipsizes instead of forcing the row past the card edge. */
|
||||
.lc-br-tag { min-width: 0; font-size: 11px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 4px; padding: 0 6px; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Registering-plugin chip on tool-schema rows: package/server names scan on a
|
||||
brand-tinted pill (the badge tint idiom), in the same face and size as the
|
||||
tool name they tag (the row inherits the UI font at 12px) so the attribution
|
||||
reads as part of the name. */
|
||||
.lc-br-tool-plugin { font-size: 12px; border-radius: 6px; color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent); border-color: transparent; }
|
||||
.lc-br-preview { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lc-br-time { flex: none; color: var(--dsw-alias-label-secondary); font-size: 11px; }
|
||||
.lc-br-content { padding: 2px 6px 8px 26px; display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/// <reference types="node" />
|
||||
/**
|
||||
* Live tool→plugin attribution layered on the static recovery in
|
||||
* toolSources.ts.
|
||||
*
|
||||
* The session log records tools as plain `ToolSchema` entries (name /
|
||||
* description / parameters) — the registering plugin is not in there.
|
||||
* toolSources.ts derives the deterministic sources (harness-logged field,
|
||||
* `mcp:<server>` naming, pinned first-party map). This module additionally
|
||||
* watches RUNTIME registrations: cordis fires the `internal/get` waterfall on
|
||||
* every context read of a service property, passing the READING context as the
|
||||
* first argument, so `reader.fiber.name` identifies the plugin that is about
|
||||
* to call `register()`.
|
||||
*
|
||||
* - The `internal/get` handler records who last read the `tools` service and
|
||||
* wraps that instance's `register` (once — an earlier wrapper of a previous
|
||||
* hook incarnation is peeled back to the original, so a plugin reload
|
||||
* re-wraps without stacking) to capture the reader at registration time
|
||||
* into a live map.
|
||||
* - When the reader slot is missing, root-named, or this plugin's own (e.g.
|
||||
* LOCAL-LINK plugins — dev installs via `dsh plugin add <path>` or
|
||||
* npm/pnpm link — whose anonymous entrypoints make cordis fall back to the
|
||||
* root name), the wrapped `register` falls back to the call stack: the first
|
||||
* frame outside this package is resolved to its nearest `package.json`
|
||||
* `name`. That covers both npm installs (`node_modules/<pkg>`) and local
|
||||
* links (any directory carrying a package.json), which never pass through
|
||||
* node_modules. Frames that resolve back to this package are skipped.
|
||||
* - `ownerOf(name)` prefers the name-derived `mcp:<server>` label (it names
|
||||
* the actual provider, where the live record would only ever name the MCP
|
||||
* proxy client), then the LIVE record — for a post-boot registration it is
|
||||
* the truth, even when the name collides with a pinned first-party tool —
|
||||
* then the pinned map (the boot-time guess for tools registered before the
|
||||
* hook), and finally tags tools that were ALREADY registered when the hook
|
||||
* installed (the boot snapshot — third-party bundles, e.g. local links like
|
||||
* dsh-file-claim, that applied before dsh-context) with the
|
||||
* `UNKNOWN_TOOL_SOURCE` sentinel: their registering plugin is unknowable,
|
||||
* and a bare gap would read as "no plugin" instead of "unknown plugin".
|
||||
*
|
||||
* Best-effort by design: a read separated from `register()` by an `await` can
|
||||
* be overwritten by another plugin's read (misattribution) and the stack
|
||||
* fallback needs a resolvable package.json — both degrade to the name/pinned
|
||||
* chain; registrations that predate the hook degrade to the unknown tag. The
|
||||
* hook costs roughly +1.4us per service-property read and is negligible on
|
||||
* the rare register path (the stack walk only runs when the reader slot is
|
||||
* unusable, and its package lookups are cached per directory).
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join, normalize } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { UNKNOWN_TOOL_SOURCE } from '../shared/types'
|
||||
import { mcpSourceOf, pinnedSourceOf } from './toolSources'
|
||||
|
||||
export interface ToolAttribution {
|
||||
/** Best-effort label of the plugin that registered `name`, if any is known. */
|
||||
ownerOf(name: string): string | undefined
|
||||
}
|
||||
|
||||
/** This module's own file URL — the stack walk skips its own frames. */
|
||||
const selfUrl = normalize(fileURLToPath(import.meta.url))
|
||||
|
||||
/** Directory → package-name cache for the synchronous walk below. */
|
||||
const packageCache = new Map<string, string | undefined>()
|
||||
|
||||
/**
|
||||
* Best-effort package name for a module file: walk up to the nearest
|
||||
* `package.json` carrying a `name`. Works for dependencies installed under
|
||||
* `node_modules` as well as local links whose package root is any on-disk
|
||||
* directory. The per-directory results are cached.
|
||||
* @param file - absolute path of a module file.
|
||||
*/
|
||||
export function packageNameFrom(file: string): string | undefined {
|
||||
let dir = dirname(file)
|
||||
for (let depth = 0; depth < 12; depth++) {
|
||||
const cached = packageCache.get(dir)
|
||||
if (cached !== undefined) return cached
|
||||
const packageFile = join(dir, 'package.json')
|
||||
if (existsSync(packageFile)) {
|
||||
try {
|
||||
const name = (JSON.parse(readFileSync(packageFile, 'utf8')) as { name?: unknown }).name
|
||||
if (typeof name === 'string' && name) {
|
||||
packageCache.set(dir, name)
|
||||
return name
|
||||
}
|
||||
} catch {
|
||||
// Malformed or unreadable package.json — keep walking up.
|
||||
}
|
||||
}
|
||||
const parent = dirname(dir)
|
||||
packageCache.set(dir, undefined)
|
||||
if (parent === dir) return undefined
|
||||
dir = parent
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const FRAME_POSITION = /:\d+:\d+$/
|
||||
|
||||
/** Package name of this module's own package (self-fallbacks are filtered). */
|
||||
const selfPackage = packageNameFrom(selfUrl)
|
||||
|
||||
/**
|
||||
* Resolve the registering package from a stack trace: walk frames from the
|
||||
* innermost out, skipping this module's own frames and frames that resolve to
|
||||
* this package, and return the package name of the first frame that resolves
|
||||
* elsewhere. Works with both `file://` URLs and bare absolute paths
|
||||
* (transpiled modules render without a scheme), with optional `fn (...)` and
|
||||
* `async` wrappers.
|
||||
* @param stack - `Error().stack`, or undefined when no fallback is desired.
|
||||
*/
|
||||
export function callerPackageFrom(stack: string | undefined): string | undefined {
|
||||
if (!stack) return undefined
|
||||
for (const raw of stack.split('\n').slice(1)) {
|
||||
let line = raw.trim()
|
||||
if (!line.startsWith('at ')) continue
|
||||
line = line.slice(3)
|
||||
if (line.startsWith('async ')) line = line.slice(6)
|
||||
line = line.replace(/\)\s*$/, '')
|
||||
const position = FRAME_POSITION.exec(line)
|
||||
if (!position) continue
|
||||
let target = line.slice(0, -position[0].length)
|
||||
if (target.includes('(')) {
|
||||
target = target.slice(target.lastIndexOf('(') + 1)
|
||||
}
|
||||
if (target.startsWith('file://')) {
|
||||
try {
|
||||
target = normalize(fileURLToPath(target))
|
||||
} catch {
|
||||
// Unparseable file URL — try the next frame.
|
||||
continue
|
||||
}
|
||||
} else if (!/^[A-Za-z]:[\\/]/.test(target) && !target.startsWith('/') && !target.startsWith('\\\\')) {
|
||||
continue
|
||||
} else {
|
||||
target = normalize(target)
|
||||
}
|
||||
if (target === selfUrl) continue
|
||||
const name = packageNameFrom(target)
|
||||
if (name !== undefined && name !== selfPackage) return name
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The tool service's boot-time surface this module reads: the global layer's name→∞ entries. */
|
||||
interface ToolServiceLike {
|
||||
layers?: {
|
||||
global?: {
|
||||
tools?: {
|
||||
entries?: () => Iterable<[string, unknown]>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the runtime-attribution hook on a cordis app context. The hook
|
||||
* rides the calling fiber's lifetime (`ctx.on` / `ctx.get`), so it is
|
||||
* disposed with the plugin.
|
||||
* @param ctx - the context the dsh-context plugin runs in; its fiber name is
|
||||
* excluded from attributions.
|
||||
*/
|
||||
export function createToolAttribution(ctx: Context): ToolAttribution {
|
||||
const live = new Map<string, string>()
|
||||
const wrapped = new WeakSet()
|
||||
const self = ctx.fiber.name
|
||||
let lastReader: Context | undefined
|
||||
|
||||
const wrapInstance = (tools: unknown) => {
|
||||
if (!tools || typeof tools !== 'object' || wrapped.has(tools)) return
|
||||
const register = (tools as { register?: unknown }).register
|
||||
if (typeof register !== 'function') return
|
||||
wrapped.add(tools)
|
||||
// A reload of this plugin re-installs the hook on a still-wrapped
|
||||
// instance: peel the previous incarnation's wrapper back to the original
|
||||
// (marked below) so wrappers never stack across reloads.
|
||||
const original = (register as { attributedOriginal?: unknown }).attributedOriginal ?? register
|
||||
if (typeof original !== 'function') return
|
||||
const instance = tools as { register: (this: unknown, definition?: { name?: unknown }) => unknown }
|
||||
const wrappedRegister = function (this: unknown, definition?: { name?: unknown }) {
|
||||
const toolName = definition?.name
|
||||
let owner = lastReader?.fiber.name
|
||||
if (!owner || owner === 'root' || owner === self) {
|
||||
owner = callerPackageFrom(new Error().stack)
|
||||
}
|
||||
const dispose = (original as (this: unknown, definition?: unknown) => unknown).call(this, definition)
|
||||
if (typeof toolName === 'string' && owner && owner !== 'root' && owner !== self && owner !== selfPackage) {
|
||||
live.set(toolName, owner)
|
||||
if (typeof dispose === 'function') {
|
||||
return () => {
|
||||
try {
|
||||
return (dispose as () => unknown)()
|
||||
} finally {
|
||||
live.delete(toolName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return dispose
|
||||
}
|
||||
;(wrappedRegister as { attributedOriginal?: unknown }).attributedOriginal = original
|
||||
instance.register = wrappedRegister
|
||||
}
|
||||
|
||||
ctx.on('internal/get', (reader, name, _error, next) => {
|
||||
if (name !== 'tools') return next() as unknown
|
||||
const tools = next() as unknown
|
||||
lastReader = reader
|
||||
wrapInstance(tools)
|
||||
return tools
|
||||
})
|
||||
|
||||
// An instance provided before this plugin started is still wrapped so later
|
||||
// registrations on it are captured. The tools it already holds were
|
||||
// registered before this hook could observe them (boot-time third-party
|
||||
// bundles that applied first — e.g. local links like dsh-file-claim), so
|
||||
// their provider is unknowable; the boot snapshot lets ownerOf tag them
|
||||
// with UNKNOWN_TOOL_SOURCE instead of silently showing nothing.
|
||||
const toolsService = ctx.get('tools', false) as ToolServiceLike | undefined
|
||||
wrapInstance(toolsService)
|
||||
const boot = new Set<string>()
|
||||
try {
|
||||
const toolEntries = toolsService?.layers?.global?.tools
|
||||
// Method-style call: NamedEntries.entries() reads `this.data`, so the
|
||||
// receiver must be preserved. Any shape surprise just leaves the boot
|
||||
// snapshot empty (static chain + live map still rule); attribution must
|
||||
// never take the caller's startup down.
|
||||
if (toolEntries !== undefined && typeof toolEntries.entries === 'function') {
|
||||
for (const [name] of toolEntries.entries()) boot.add(name)
|
||||
}
|
||||
} catch {
|
||||
// Unsupported tool-service internals — degrade to no boot snapshot.
|
||||
}
|
||||
|
||||
return {
|
||||
// Priority: the name-derived MCP label names the actual provider (the
|
||||
// live record would only ever name the proxying client plugin); a live
|
||||
// record outranks the pinned map (for a post-boot registration it IS the
|
||||
// truth, even under a first-party-looking name); the pinned map is the
|
||||
// boot-time guess; the sentinel marks boot-predating tools.
|
||||
ownerOf: name =>
|
||||
mcpSourceOf(name) ?? live.get(name) ?? pinnedSourceOf(name)
|
||||
?? (boot.has(name) ? UNKNOWN_TOOL_SOURCE : undefined),
|
||||
}
|
||||
}
|
||||
+20
-3
@@ -34,6 +34,7 @@ const headerToolSchema = z.object({
|
||||
name: z.string(),
|
||||
tokens: z.number().int().nonnegative(),
|
||||
description: z.string().optional(),
|
||||
plugin: z.string().optional(),
|
||||
schema: z.unknown().optional(),
|
||||
}).strict()
|
||||
|
||||
@@ -64,7 +65,7 @@ function recordOf(event: SessionEvent): HeaderRecord | null {
|
||||
tools: tools.map((t): HeaderTool => {
|
||||
// The log is untrusted input: a null or primitive entry degrades to an
|
||||
// unnamed, JSON-priced tool instead of throwing the fold.
|
||||
const tool = (t !== null && typeof t === 'object' ? t : {}) as { name?: unknown; description?: unknown }
|
||||
const tool = (t !== null && typeof t === 'object' ? t : {}) as { name?: unknown; description?: unknown; plugin?: unknown }
|
||||
const entry: HeaderTool = {
|
||||
name: typeof tool.name === 'string' ? tool.name : '?',
|
||||
tokens: estimateToolSchema(t),
|
||||
@@ -73,6 +74,11 @@ function recordOf(event: SessionEvent): HeaderRecord | null {
|
||||
if (typeof tool.description === 'string' && tool.description !== '') {
|
||||
entry.description = tool.description
|
||||
}
|
||||
// A harness/MCP-provided attribution rides the raw entry; kept verbatim
|
||||
// so the view-time resolver never overrides it.
|
||||
if (typeof tool.plugin === 'string' && tool.plugin !== '') {
|
||||
entry.plugin = tool.plugin
|
||||
}
|
||||
return entry
|
||||
}),
|
||||
}
|
||||
@@ -86,10 +92,21 @@ function recordOf(event: SessionEvent): HeaderRecord | null {
|
||||
* The context-headers projection unit; registered alongside the timeline unit (host/index.ts); clients read it through
|
||||
* `useProjection('contextHeaders')` and degrade to tokens-only header sections when the key is absent. Dual-contract definition (see
|
||||
* compat.ts).
|
||||
* @param resolve - best-effort tool-to-plugin attribution (see toolSources.ts); fills a missing `plugin` at view time so
|
||||
* epochs folded without attribution still render a tag when the source is known.
|
||||
*/
|
||||
export function createContextHeadersDefinition(): ProjectionDefinition<'contextHeaders', HeadersState> {
|
||||
export function createContextHeadersDefinition(
|
||||
resolve?: (name: string) => string | undefined,
|
||||
): ProjectionDefinition<'contextHeaders', HeadersState> {
|
||||
const view = (state: HeadersState): ContextHeaders => ({
|
||||
headers: state.headers.map(h => ({ ...h, tools: h.tools.map(t => ({ ...t })) })),
|
||||
headers: state.headers.map(h => ({
|
||||
...h,
|
||||
tools: h.tools.map((t) => {
|
||||
if (resolve === undefined || t.plugin !== undefined) return { ...t }
|
||||
const plugin = resolve(t.name)
|
||||
return plugin === undefined ? { ...t } : { ...t, plugin }
|
||||
}),
|
||||
})),
|
||||
})
|
||||
const definition: CompatProjectionDefinition<'contextHeaders', HeadersState> = {
|
||||
key: 'contextHeaders',
|
||||
|
||||
+7
-1
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { createToolAttribution } from './attribution'
|
||||
import { Config } from './config'
|
||||
import { createContextHeadersDefinition } from './headers'
|
||||
import { installSettings } from './settings'
|
||||
@@ -34,8 +35,13 @@ export const inject = ['sessionProjections']
|
||||
export { Config } from './config'
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// Tool-to-plugin attribution (see attribution.ts): the static chain from
|
||||
// toolSources.ts stays the backbone, the runtime register() hook adds
|
||||
// third-party / agent-scoped / dynamic tools on top. Strictly additive — an
|
||||
// unsupported cordis or a missed read degrades to the static chain.
|
||||
const attribution = createToolAttribution(ctx)
|
||||
ctx.sessionProjections.register(createContextTimelineDefinition(config))
|
||||
ctx.sessionProjections.register(createContextHeadersDefinition())
|
||||
ctx.sessionProjections.register(createContextHeadersDefinition(name => attribution.ownerOf(name)))
|
||||
installSettings(ctx)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Tool-to-plugin attribution for the Context browser's tool schema rows.
|
||||
*
|
||||
* The durable `request/header` event logs each tool as a plain `ToolSchema`
|
||||
* (`name`/`description`/`parameters` only) — the harness strips everything
|
||||
* else at assembly, so the registering plugin is not in the session log. This
|
||||
* module recovers what it can without a harness change:
|
||||
*
|
||||
* 1. A `plugin` field a (future) harness or MCP client carries on the raw
|
||||
* header entry — passed through verbatim by headers.ts's recordOf, ahead
|
||||
* of everything here.
|
||||
* 2. MCP tools: `dsh-mcp-client` names every proxied tool
|
||||
* `mcp__<server>__<rawName>`, so the server is recoverable from the name.
|
||||
* 3. Shipped first-party tools: a PINNED name → package map derived from the
|
||||
* deepseek-harness tool-schema catalog (packages/core/tools + the shipped
|
||||
* `tool-*` packages at dsh 0.1.1-rc.2; names are stable across the plugin's
|
||||
* supported 0.1.0-rc7+ / 0.1.1-rc2+ range). Third-party tools stay
|
||||
* unattributed rather than guessed.
|
||||
*
|
||||
* Pure string functions: fully unit-testable, no harness imports.
|
||||
*/
|
||||
|
||||
/** MCP server-name prefix of `dsh-mcp-client` proxied tools. */
|
||||
export const MCP_PREFIX = 'mcp__'
|
||||
|
||||
/**
|
||||
* Recover the MCP server display label from a proxied tool name, or undefined
|
||||
* for non-MCP names. `dsh-mcp-client` names tools `mcp__<server>__<rawName>`
|
||||
* (normalized, and hash-appended when overlong/invalid — the label then shows
|
||||
* whatever of the server survived the truncation). The separating `__` is the
|
||||
* LAST one in the name, and it must sit AFTER the `mcp__` prefix: the prefix's
|
||||
* own separator (or an empty server right after it) is not a server.
|
||||
*/
|
||||
export function mcpServerOf(name: string): string | undefined {
|
||||
if (!name.startsWith(MCP_PREFIX)) return undefined
|
||||
const cut = name.lastIndexOf('__')
|
||||
if (cut < MCP_PREFIX.length) return undefined
|
||||
const server = name.slice(MCP_PREFIX.length, cut)
|
||||
return server.length > 0 ? server : undefined
|
||||
}
|
||||
|
||||
/** The `mcp:<server>` display label of a proxied tool name, or undefined. */
|
||||
export function mcpSourceOf(name: string): string | undefined {
|
||||
const server = mcpServerOf(name)
|
||||
return server !== undefined ? `mcp:${server}` : undefined
|
||||
}
|
||||
|
||||
/** The pinned package of a first-party tool name, or undefined. */
|
||||
export function pinnedSourceOf(name: string): string | undefined {
|
||||
return FIRST_PARTY_SOURCES[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Pinned first-party tool → plugin package map (see header comment). One entry
|
||||
* per model-facing name of the shipped tool packages at dsh 0.1.1-rc.2, from
|
||||
* the official tool-schema catalog; names are stable across the supported
|
||||
* harness range. Packages that mount under distinct names per composition
|
||||
* (bash/pwsh persistent variants, subagent fork) map to their primary package.
|
||||
*/
|
||||
export const FIRST_PARTY_SOURCES: Readonly<Record<string, string>> = Object.freeze({
|
||||
read: '@deepseek-ai/dsh-tool-fs',
|
||||
write: '@deepseek-ai/dsh-tool-fs',
|
||||
edit: '@deepseek-ai/dsh-tool-fs',
|
||||
read_image: '@deepseek-ai/dsh-tool-fs',
|
||||
glob: '@deepseek-ai/dsh-tool-fs-search',
|
||||
grep: '@deepseek-ai/dsh-tool-fs-search',
|
||||
str_replace_editor: '@deepseek-ai/dsh-tool-str-replace-editor',
|
||||
bash: '@deepseek-ai/dsh-tool-bash',
|
||||
pwsh: '@deepseek-ai/dsh-tool-pwsh',
|
||||
web_search: '@deepseek-ai/dsh-tool-web',
|
||||
web_fetch: '@deepseek-ai/dsh-tool-web',
|
||||
job_output: '@deepseek-ai/dsh-tool-jobs',
|
||||
job_list: '@deepseek-ai/dsh-tool-jobs',
|
||||
job_kill: '@deepseek-ai/dsh-tool-jobs',
|
||||
ask_user_question: '@deepseek-ai/dsh-tool-ask-user',
|
||||
plan: '@deepseek-ai/dsh-plan-mode',
|
||||
exit_plan_mode: '@deepseek-ai/dsh-plan-mode',
|
||||
skill: '@deepseek-ai/dsh-tool-skill',
|
||||
todo_write: '@deepseek-ai/dsh-tool-todo',
|
||||
subagent: '@deepseek-ai/dsh-tool-subagent',
|
||||
subagent_fork: '@deepseek-ai/dsh-tool-subagent',
|
||||
send_message: '@deepseek-ai/dsh-tool-subagent-control',
|
||||
interrupt_agent: '@deepseek-ai/dsh-tool-subagent-control',
|
||||
list_agents: '@deepseek-ai/dsh-tool-subagent-control',
|
||||
ralph: '@deepseek-ai/dsh-tool-ralph',
|
||||
workflow: '@deepseek-ai/dsh-tool-workflow',
|
||||
run_code: '@deepseek-ai/dsh-tools',
|
||||
schedule_create: '@deepseek-ai/dsh-schedule',
|
||||
schedule_list: '@deepseek-ai/dsh-schedule',
|
||||
schedule_delete: '@deepseek-ai/dsh-schedule',
|
||||
create_goal: '@deepseek-ai/dsh-tool-goal',
|
||||
get_goal: '@deepseek-ai/dsh-tool-goal',
|
||||
update_goal: '@deepseek-ai/dsh-tool-goal',
|
||||
lsp: '@deepseek-ai/dsh-tool-lsp',
|
||||
})
|
||||
@@ -278,12 +278,31 @@ export interface ContextEventRecord {
|
||||
step?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel `HeaderTool.plugin` value marking a tool whose provider could not
|
||||
* be attributed: it was already registered in the harness tool service when
|
||||
* this plugin's runtime attribution hook installed (boot-time third-party
|
||||
* tools — e.g. local-link plugins that apply before dsh-context). The client
|
||||
* renders a localized "unknown plugin" tag with an explanatory tooltip. No
|
||||
* real plugin name can collide (it is not a valid package identifier).
|
||||
*/
|
||||
export const UNKNOWN_TOOL_SOURCE = '<unknown-plugin>'
|
||||
|
||||
/** One tool schema as assembled into a request header, with its display price. */
|
||||
export interface HeaderTool {
|
||||
name: string
|
||||
tokens: number
|
||||
/** Producer-declared description (may be long; the browser truncates). */
|
||||
description?: string
|
||||
/**
|
||||
* The registering plugin's label, when attribution is known: either a
|
||||
* `plugin` field carried by the raw header entry (harness-provided) or the
|
||||
* host's best-effort attribution (`mcp:<server>` for MCP tools, or the
|
||||
* pinned first-party package map). `UNKNOWN_TOOL_SOURCE` marks a tool whose
|
||||
* provider predates the attribution hook; absent means nothing is known and
|
||||
* the browser shows no tag.
|
||||
*/
|
||||
plugin?: string
|
||||
/** The raw JSON schema object the model received (plain JSON). */
|
||||
schema?: unknown
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { describe, test } from 'vitest'
|
||||
import { h } from '../../../src/client/react'
|
||||
import { makeContextBrowser, type ContextBrowserProps } from '../../../src/client/components/browser'
|
||||
import { makeStackedBar } from '../../../src/client/components/stackedBar'
|
||||
import type { ContextHeaders, ContextTimeline, RequestRecord, SurfaceNode } from '../../../src/shared/types'
|
||||
import { UNKNOWN_TOOL_SOURCE, type ContextHeaders, type ContextTimeline, type RequestRecord, type SurfaceNode } from '../../../src/shared/types'
|
||||
import type { ConversationNodeLike, ImageLoader, UseSessionLike } from '../../../src/client/services'
|
||||
import { click, flush, hover, makeKit, mount, query, queryAll, text, unhover, type Mounted } from '../helpers/kit'
|
||||
|
||||
@@ -510,6 +510,41 @@ describe('ContextBrowser tool schemas', () => {
|
||||
assert.equal(text(body), '')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('tool rows tag the registering plugin when attribution exists', async () => {
|
||||
const attributed: ContextHeaders = {
|
||||
headers: [{ seq: 1, time: 1, system: 'SYS', tools: [
|
||||
{ name: 'write', tokens: 5, plugin: '@deepseek-ai/dsh-tool-fs' },
|
||||
{ name: 'mcp__github__get_issue', tokens: 3, plugin: 'mcp:github' },
|
||||
{ name: 'agent_teams_add_member', tokens: 2 }, // no attribution → no chip
|
||||
// Boot-predating tools arrive with the unknown sentinel → localized tag.
|
||||
{ name: 'claim_files', tokens: 2, plugin: UNKNOWN_TOOL_SOURCE },
|
||||
] }],
|
||||
}
|
||||
const data = tl({ current: { system: 0, tools: 10, user: 0, inject: 0, assistant: 0, tool: 0, total: 10 } })
|
||||
const m = await mount(h(Browser, props({ data, headers: attributed })))
|
||||
await click(catRow(m, 'tools'))
|
||||
const rows = elemRows(m)
|
||||
assert.equal(rows.length, 4)
|
||||
const chips = queryAll(m.container, '.lc-br-tool-plugin')
|
||||
assert.equal(chips.length, 3, 'only attributed tools chip')
|
||||
assert.ok(text(chips[0]).includes('@deepseek-ai/dsh-tool-fs'))
|
||||
assert.ok(text(chips[1]).includes('mcp:github'))
|
||||
assert.ok((chips[0] as HTMLElement).title.includes('The registering plugin of this tool'), 'the chip carries the i18n tooltip')
|
||||
assert.ok(!text(rows[2]).includes('@'), 'unattributed tools stay untagged')
|
||||
// The unknown-source sentinel renders the localized tag with the
|
||||
// boot-timing explanation, never the raw sentinel string.
|
||||
assert.equal(chips[2].textContent, 'Unknown plugin')
|
||||
assert.ok((chips[2] as HTMLElement).title.includes('registered before the context plugin loaded'), 'unknown chips explain the boot gap')
|
||||
// Layout: the tool name leads the row, the plugin chip trails it directly
|
||||
// — one frame only, so `lc-br-tag` must not nest another `lc-br-tag`.
|
||||
assert.equal(text(query(rows[0], '.lc-br-preview')), 'write', 'tool name leads')
|
||||
const chip = chips[0] as HTMLElement
|
||||
assert.equal(chip.previousElementSibling, query(rows[0], '.lc-br-preview'), 'plugin chip sits right after the tool name')
|
||||
assert.equal(chip.parentElement!.className, 'lc-br-elem-row', 'plugin chip is a single frame, a direct row child')
|
||||
assert.equal(queryAll(chip, '.lc-br-tag').length, 0, 'no nested tag wrapper')
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContextBrowser message categories', () => {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// Unit tests for the runtime register() attribution hook (src/host/attribution.ts)
|
||||
// against a REAL cordis Context: plugin fibers read `ctx.tools` (firing the
|
||||
// internal/get waterfall) and call `register()` on a fake tools service.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, test } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { UNKNOWN_TOOL_SOURCE } from '../../src/shared/types'
|
||||
import { callerPackageFrom, createToolAttribution, packageNameFrom, type ToolAttribution } from '../../src/host/attribution'
|
||||
|
||||
/** Render an absolute fs path as a `file://` URL for synthetic stack frames. */
|
||||
const fileUrl = (file: string) => 'file:///' + file.replace(/\\/g, '/')
|
||||
|
||||
/** Absolute path of this spec file (a file inside the dsh-context package). */
|
||||
const specFile = path.normalize(fileURLToPath(import.meta.url))
|
||||
|
||||
describe('createToolAttribution', () => {
|
||||
test('falls back to the static chain when nothing was registered at runtime', () => {
|
||||
const app = new Context()
|
||||
const attribution = createToolAttribution(app)
|
||||
assert.equal(attribution.ownerOf('read'), '@deepseek-ai/dsh-tool-fs')
|
||||
assert.equal(attribution.ownerOf('mcp__github__get_issue'), 'mcp:github')
|
||||
assert.equal(attribution.ownerOf('unknown_tool'), undefined)
|
||||
})
|
||||
|
||||
test('attributes tools to the plugin that read ctx.tools and registered them', async () => {
|
||||
const app = new Context()
|
||||
const attribution = createToolAttribution(app)
|
||||
const fake = { register() { return () => {} } }
|
||||
app.provide('tools', fake)
|
||||
let release: () => void = () => {}
|
||||
await app.plugin({
|
||||
name: 'provider-a',
|
||||
apply(sub) {
|
||||
const tools = (sub as any).tools
|
||||
assert.equal((sub as any).tools, fake, 'a wrapped instance is returned as-is')
|
||||
release = tools.register({ name: 'alpha' })
|
||||
tools.register({ name: 'beta' })
|
||||
},
|
||||
})
|
||||
assert.equal(attribution.ownerOf('alpha'), 'provider-a')
|
||||
assert.equal(attribution.ownerOf('beta'), 'provider-a')
|
||||
release()
|
||||
assert.equal(attribution.ownerOf('alpha'), undefined, 'the disposer forgets the tool')
|
||||
assert.equal(attribution.ownerOf('beta'), 'provider-a')
|
||||
})
|
||||
|
||||
test('falls back to the caller stack when no tools read happened', async () => {
|
||||
const app = new Context()
|
||||
const fake = { register() { return () => {} } }
|
||||
app.provide('tools', fake)
|
||||
const attribution = createToolAttribution(app)
|
||||
const { registerBlind } = await import('./fixtures/anon-plugin/index.js')
|
||||
const tools = app.get('tools', false) as { register(def?: unknown): () => void }
|
||||
const release = registerBlind(tools)
|
||||
assert.equal(attribution.ownerOf('blind_tool'), 'anon-plugin', 'no reader → the caller stack wins')
|
||||
release()
|
||||
assert.equal(attribution.ownerOf('blind_tool'), undefined, 'the disposer forgets the tool')
|
||||
})
|
||||
|
||||
test('root and self reader slots fall back to the caller stack', async () => {
|
||||
const app = new Context()
|
||||
const fake = { register() { return () => {} } }
|
||||
app.provide('tools', fake)
|
||||
const { registerBlind } = await import('./fixtures/anon-plugin/index.js')
|
||||
let attribution!: ToolAttribution
|
||||
await app.plugin({
|
||||
name: 'dsh-context',
|
||||
inject: ['tools'],
|
||||
apply(sub) {
|
||||
attribution = createToolAttribution(sub)
|
||||
registerBlind((sub as any).tools)
|
||||
},
|
||||
})
|
||||
await app.plugin({
|
||||
apply(sub) {
|
||||
registerBlind((sub as any).tools)
|
||||
},
|
||||
})
|
||||
assert.equal(attribution.ownerOf('blind_tool'), 'anon-plugin', 'self/root slots are never taken as-is')
|
||||
})
|
||||
|
||||
test('ignores non-tools reads and keeps the static chain intact', async () => {
|
||||
const app = new Context()
|
||||
const attribution = createToolAttribution(app)
|
||||
app.provide('bench', { value: 1 })
|
||||
await app.plugin({
|
||||
name: 'probe',
|
||||
apply(sub) {
|
||||
void (sub as any).bench.value
|
||||
void (sub as any).bench.value
|
||||
},
|
||||
})
|
||||
assert.equal(attribution.ownerOf('grep'), '@deepseek-ai/dsh-tool-fs-search')
|
||||
})
|
||||
|
||||
test('tolerates falsy and non-object tools values', async () => {
|
||||
for (const value of [undefined, 42]) {
|
||||
const app = new Context()
|
||||
const attribution = createToolAttribution(app)
|
||||
app.provide('tools', value)
|
||||
await app.plugin({
|
||||
name: 'probe',
|
||||
apply(sub) {
|
||||
void (sub as any).tools
|
||||
},
|
||||
})
|
||||
assert.equal(attribution.ownerOf('x'), undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('leaves instances without a callable register untouched', async () => {
|
||||
for (const tools of [{ register: 42 }, {}]) {
|
||||
const app = new Context()
|
||||
const attribution = createToolAttribution(app)
|
||||
app.provide('tools', tools)
|
||||
await app.plugin({
|
||||
name: 'probe',
|
||||
apply(sub) {
|
||||
void (sub as any).tools
|
||||
},
|
||||
})
|
||||
assert.equal(attribution.ownerOf('whatever'), undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('a foreign wrapper carrying a garbage attribution marker is left untouched', async () => {
|
||||
const app = new Context()
|
||||
const attribution = createToolAttribution(app)
|
||||
// A wrapper that copied the peel-marker name with a non-function value:
|
||||
// there is no original to recover, so the instance is not re-wrapped.
|
||||
const foreign = Object.assign(() => 'not-a-disposer', { attributedOriginal: 42 })
|
||||
const fake = { register: foreign }
|
||||
app.provide('tools', fake)
|
||||
await app.plugin({
|
||||
name: 'probe',
|
||||
apply(sub) {
|
||||
void (sub as any).tools
|
||||
},
|
||||
})
|
||||
assert.equal((app.get('tools', false) as typeof fake).register, foreign, 'the instance was not re-wrapped')
|
||||
assert.equal(attribution.ownerOf('whatever'), undefined)
|
||||
})
|
||||
|
||||
test('ignores registrations without a string tool name or a disposer', async () => {
|
||||
const app = new Context()
|
||||
const attribution = createToolAttribution(app)
|
||||
app.provide('tools', { register() { return undefined } })
|
||||
await app.plugin({
|
||||
name: 'weird',
|
||||
apply(sub) {
|
||||
const tools = (sub as any).tools
|
||||
tools.register()
|
||||
tools.register({ name: 42 })
|
||||
tools.register({ name: 'sticky' })
|
||||
},
|
||||
})
|
||||
assert.equal(attribution.ownerOf('sticky'), 'weird', 'records even without a disposer')
|
||||
assert.equal(attribution.ownerOf('42'), undefined, 'non-string tool names are not recorded')
|
||||
})
|
||||
|
||||
test('the live record outranks the pinned map; name-derived MCP labels outrank the live record', async () => {
|
||||
const app = new Context()
|
||||
const attribution = createToolAttribution(app)
|
||||
const fake = { register() { return () => {} } }
|
||||
app.provide('tools', fake)
|
||||
await app.plugin({
|
||||
name: 'third-party',
|
||||
apply(sub) {
|
||||
const tools = (sub as any).tools
|
||||
tools.register({ name: 'bash' })
|
||||
tools.register({ name: 'mcp__github__x' })
|
||||
tools.register({ name: 'brand_new_tool' })
|
||||
},
|
||||
})
|
||||
assert.equal(attribution.ownerOf('bash'), 'third-party', 'a post-boot registration is the truth, even under a first-party name')
|
||||
assert.equal(attribution.ownerOf('mcp__github__x'), 'mcp:github', 'the name-derived MCP label names the actual provider')
|
||||
assert.equal(attribution.ownerOf('brand_new_tool'), 'third-party', 'live record fills the gap')
|
||||
})
|
||||
|
||||
test('a pinned first-party name still resolves when it was never registered at runtime', () => {
|
||||
const app = new Context()
|
||||
const attribution = createToolAttribution(app)
|
||||
assert.equal(attribution.ownerOf('bash'), '@deepseek-ai/dsh-tool-bash', 'the pinned map serves boot-time first-party tools')
|
||||
})
|
||||
|
||||
test('re-installing the hook re-wraps the instance without stacking wrappers', async () => {
|
||||
const app = new Context()
|
||||
let calls = 0
|
||||
app.provide('tools', { register() { calls++; return () => {} } })
|
||||
const first = createToolAttribution(app)
|
||||
void first
|
||||
// The second hook incarnation (a reload) peels the first wrapper back to
|
||||
// the original: a registration records into the NEW hook only, and the
|
||||
// original register still runs exactly once.
|
||||
const second = createToolAttribution(app)
|
||||
await app.plugin({
|
||||
name: 'provider-b',
|
||||
apply(sub) {
|
||||
;((sub as any).tools as { register(def?: unknown): () => void }).register({ name: 'reload_tool' })
|
||||
},
|
||||
})
|
||||
assert.equal(calls, 1, 'the original register ran once, through one wrapper only')
|
||||
assert.equal(second.ownerOf('reload_tool'), 'provider-b', 'the fresh hook records the registration')
|
||||
})
|
||||
|
||||
test('tags boot-time tools whose providers predate the hook as unknown', () => {
|
||||
const app = new Context()
|
||||
const fake = {
|
||||
register() { return () => {} },
|
||||
layers: { global: { tools: { entries: () => [
|
||||
['claim_files', {}],
|
||||
['pending_write', {}],
|
||||
['read', {}],
|
||||
] } } },
|
||||
}
|
||||
app.provide('tools', fake)
|
||||
const attribution = createToolAttribution(app)
|
||||
assert.equal(attribution.ownerOf('claim_files'), UNKNOWN_TOOL_SOURCE, 'boot-only tools carry the unknown sentinel')
|
||||
assert.equal(attribution.ownerOf('pending_write'), UNKNOWN_TOOL_SOURCE)
|
||||
assert.equal(attribution.ownerOf('read'), '@deepseek-ai/dsh-tool-fs', 'the pinned map wins over the unknown tag')
|
||||
assert.equal(attribution.ownerOf('nope'), undefined, 'names outside the boot snapshot stay untagged')
|
||||
})
|
||||
|
||||
test('a later runtime registration overrides the boot-time unknown tag', async () => {
|
||||
const app = new Context()
|
||||
const fake = {
|
||||
register() { return () => {} },
|
||||
layers: { global: { tools: { entries: () => [['claim_files', {}]] } } },
|
||||
}
|
||||
app.provide('tools', fake)
|
||||
const attribution = createToolAttribution(app)
|
||||
await app.plugin({
|
||||
name: 'claim-like',
|
||||
apply(sub) {
|
||||
;((sub as any).tools as { register(def?: unknown): () => void }).register({ name: 'claim_files' })
|
||||
},
|
||||
})
|
||||
assert.equal(attribution.ownerOf('claim_files'), 'claim-like', 'a later live record beats the boot unknown tag')
|
||||
})
|
||||
|
||||
test('tolerates tools values without an enumerable boot layer', () => {
|
||||
for (const tools of [
|
||||
undefined,
|
||||
{ register() { return () => {} } },
|
||||
{ register() { return () => {} }, layers: {} },
|
||||
{ register() { return () => {} }, layers: { global: {} } },
|
||||
{ register() { return () => {} }, layers: { global: { tools: {} } } },
|
||||
// A host tool service whose enumeration throws must not take the
|
||||
// caller's startup down — the boot snapshot just stays empty.
|
||||
{
|
||||
register() { return () => {} },
|
||||
layers: { global: { tools: { entries() { throw new Error('boom') } } } },
|
||||
},
|
||||
]) {
|
||||
const app = new Context()
|
||||
if (tools !== undefined) app.provide('tools', tools)
|
||||
const attribution = createToolAttribution(app)
|
||||
assert.equal(attribution.ownerOf('whatever'), undefined, 'no boot entries → no unknown tag')
|
||||
}
|
||||
})
|
||||
|
||||
test('resolves root-named local-link plugins through the register call stack', async () => {
|
||||
const app = new Context()
|
||||
const attribution = createToolAttribution(app)
|
||||
const fake = { register() { return () => {} } }
|
||||
app.provide('tools', fake)
|
||||
const { registerTool } = await import('./fixtures/local-plugin/index.js')
|
||||
await app.plugin({
|
||||
apply(sub) {
|
||||
registerTool((sub as any).tools)
|
||||
},
|
||||
})
|
||||
assert.equal(attribution.ownerOf('local_tool'), 'local-plugin')
|
||||
})
|
||||
|
||||
test('callerPackageFrom resolves the registering package from a stack', () => {
|
||||
const anon = path.normalize(fileURLToPath(new URL('./fixtures/anon-plugin/index.js', import.meta.url)))
|
||||
const local = path.normalize(fileURLToPath(new URL('./fixtures/local-plugin/index.js', import.meta.url)))
|
||||
assert.equal(callerPackageFrom(undefined), undefined)
|
||||
assert.equal(callerPackageFrom('no frames here'), undefined)
|
||||
// non-frame lines are skipped
|
||||
assert.equal(callerPackageFrom(`Error\n note\n at ${local}:1:2`), 'local-plugin')
|
||||
// frames without a :line:col position are skipped, unresolvable targets yield undefined
|
||||
assert.equal(callerPackageFrom('Error\n at x (file:///D:/no-position.js)\n at y (file:///D:/has-position.js:1:2)'), undefined)
|
||||
// bare absolute path frames (transpiled modules) resolve
|
||||
assert.equal(callerPackageFrom(`Error\n at ${local}:1:2\n at ${specFile}:3:4`), 'local-plugin')
|
||||
// file:// URL frames with fn (...) wrappers resolve
|
||||
assert.equal(callerPackageFrom(`Error\n at registerBlind (${fileUrl(anon)}:1:2)\n at run (${fileUrl(specFile)}:3:4)`), 'anon-plugin')
|
||||
// frames resolving to this package are skipped, the next package wins
|
||||
assert.equal(callerPackageFrom(`Error\n at ${specFile}:1:2\n at ${local}:3:4`), 'local-plugin')
|
||||
// this module's own frames are skipped too
|
||||
const attributionFile = path.normalize(fileURLToPath(new URL('../../src/host/attribution.ts', import.meta.url)))
|
||||
assert.equal(callerPackageFrom(`Error\n at ${attributionFile}:1:2\n at ${local}:3:4`), 'local-plugin')
|
||||
// unparseable file URLs are skipped
|
||||
assert.equal(callerPackageFrom(`Error\n at x (file:///D:/bad%zz.js:1:2)\n at y (${fileUrl(local)}:3:4)`), 'local-plugin')
|
||||
// non-absolute targets (e.g. node: internal frames) are skipped
|
||||
assert.equal(callerPackageFrom(`Error\n at ModuleJob.run (node:internal/modules/esm/module_job.js:346:20)\n at y (${fileUrl(local)}:3:4)`), 'local-plugin')
|
||||
// async wrappers are tolerated
|
||||
assert.equal(callerPackageFrom(`Error\n at async fn (${fileUrl(anon)}:1:2)`), 'anon-plugin')
|
||||
})
|
||||
|
||||
test('packageNameFrom walks up to the nearest readable package.json', () => {
|
||||
const local = fileURLToPath(new URL('./fixtures/local-plugin/index.js', import.meta.url))
|
||||
assert.equal(packageNameFrom(local), 'local-plugin')
|
||||
assert.equal(packageNameFrom(local), 'local-plugin', 'results are cached per directory')
|
||||
const malformed = fileURLToPath(new URL('./fixtures/bad-pkg/index.js', import.meta.url))
|
||||
assert.equal(packageNameFrom(malformed), 'dsh-context', 'a malformed package.json is skipped, the walk continues upward')
|
||||
const rootless = path.join(path.parse(process.cwd()).root, '__dsh_context_nonexistent__', 'file.js')
|
||||
assert.equal(packageNameFrom(rootless), undefined, 'no readable boundary up to the filesystem root')
|
||||
const deep = path.join(path.parse(process.cwd()).root, ...Array.from({ length: 13 }, (_, i) => `__dsh_ctx_l${i}__`), 'file.js')
|
||||
assert.equal(packageNameFrom(deep), undefined, 'the walk-depth cap returns undefined before reaching the root')
|
||||
})
|
||||
})
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
// Typing for the throwaway plain-JS fixture (the runtime module mirrors this
|
||||
// exactly); keeps tsc happy without allowJs.
|
||||
export function registerBlind(tools: { register(def?: unknown): () => void }): () => void
|
||||
@@ -0,0 +1,7 @@
|
||||
// Throwaway fixture emulating an anonymous locally linked plugin: its
|
||||
// entrypoint carries no name (so cordis falls back to the root fiber name),
|
||||
// and it registers a tool straight from its own package directory.
|
||||
|
||||
export function registerBlind(tools) {
|
||||
return tools.register({ name: 'blind_tool' })
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "anon-plugin",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Throwaway fixture with a malformed package.json: exercises the fallback
|
||||
// walk that keeps ascending to a readable package boundary.
|
||||
|
||||
export function broken() {
|
||||
return 'broken'
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "bad-pkg",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"name-extra": "malformed json"
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
// Typing for the throwaway plain-JS fixture (the runtime module mirrors this
|
||||
// exactly); keeps tsc happy without allowJs.
|
||||
export function registerTool(tools: { register(def?: unknown): () => void }): void
|
||||
@@ -0,0 +1,7 @@
|
||||
// Throwaway fixture emulating a locally linked third-party plugin: it registers
|
||||
// tools against a tools service instance handed to it (like a real plugin
|
||||
// calling `ctx.tools.register(...)` from inside its own package directory).
|
||||
|
||||
export function registerTool(tools) {
|
||||
tools.register({ name: 'local_tool', description: 'from a local link' })
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -155,6 +155,60 @@ describe('createContextHeadersDefinition', () => {
|
||||
assert.equal(c.wire.view, def.view, 'the wire block shares the view function')
|
||||
assert.equal(c.wire.viewSchema.safeParse(c.wire.view(state)).success, true)
|
||||
})
|
||||
|
||||
test('a harness-provided plugin field rides the raw tool entry verbatim', () => {
|
||||
const def = createContextHeadersDefinition()
|
||||
const view = def.view(fold(def, [headerEvent(1, {
|
||||
tools: [
|
||||
{ name: 'mcp__github__get_issue', plugin: 'mcp:github' },
|
||||
{ name: 'plain', plugin: '' }, // empty plugin behaves as absent
|
||||
{ name: 'naked' },
|
||||
],
|
||||
})]))
|
||||
const tools = view.headers[0].tools
|
||||
assert.equal(tools[0].plugin, 'mcp:github')
|
||||
assert.ok(!('plugin' in tools[1]))
|
||||
assert.ok(!('plugin' in tools[2]))
|
||||
})
|
||||
|
||||
test('the resolver fills an absent plugin at view time and never overrides a logged one', () => {
|
||||
const resolve = (name: string): string | undefined =>
|
||||
name === 'bash' ? '@deepseek-ai/dsh-tool-bash' : name === 'mcp__github__x' ? 'mcp:github' : undefined
|
||||
const def = createContextHeadersDefinition(resolve)
|
||||
const view = def.view(fold(def, [headerEvent(1, {
|
||||
tools: [
|
||||
{ name: 'bash' },
|
||||
{ name: 'mcp__github__x' },
|
||||
{ name: 'bash', plugin: 'logged-owner' }, // logged wins over the resolver
|
||||
{ name: 'unknown' },
|
||||
],
|
||||
})]))
|
||||
const tools = view.headers[0].tools
|
||||
assert.equal(tools[0].plugin, '@deepseek-ai/dsh-tool-bash')
|
||||
assert.equal(tools[1].plugin, 'mcp:github')
|
||||
assert.equal(tools[2].plugin, 'logged-owner')
|
||||
assert.ok(!('plugin' in tools[3]))
|
||||
// The fill is a view-time projection: the folded state stays pure.
|
||||
assert.ok(!('plugin' in fold(def, [headerEvent(9, { tools: [{ name: 'bash' }] })]).headers[0].tools[0]))
|
||||
})
|
||||
|
||||
test('without a resolver no plugin is ever added', () => {
|
||||
const def = createContextHeadersDefinition()
|
||||
const view = def.view(fold(def, [headerEvent(1, { tools: [{ name: 'mcp__github__x' }] })]))
|
||||
assert.ok(!('plugin' in view.headers[0].tools[0]))
|
||||
})
|
||||
|
||||
test('plugin-bearing state and views pass both schemas', () => {
|
||||
const def = createContextHeadersDefinition(name => (name === 'bash' ? '@deepseek-ai/dsh-tool-bash' : undefined))
|
||||
const state = fold(def, [headerEvent(1, {
|
||||
tools: [{ name: 'bash', plugin: 'mcp:github' }, { name: 'read' }],
|
||||
})])
|
||||
const view = def.view(state)
|
||||
assert.equal(def.schema.safeParse(view).success, true)
|
||||
const c = compat(def)
|
||||
assert.deepEqual(c.stateSchema.parse(structuredClone(state)), state)
|
||||
assert.equal(c.wire.viewSchema.safeParse(c.wire.view(state)).success, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hostile tool entries', () => {
|
||||
|
||||
@@ -103,6 +103,58 @@ describe('dsh-context host plugin', () => {
|
||||
assert.ok(seen.some(e => e.key === 'contextHeaders'), 'contextHeaders changes notified')
|
||||
})
|
||||
|
||||
test('tool schema rows carry best-effort plugin attribution through real appends', async () => {
|
||||
const { ctx } = await boot()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('request/header', {
|
||||
header: {
|
||||
config: { model: 'deepseek-v4-flash', provider: 'deepseek' },
|
||||
tools: [
|
||||
{ name: 'bash', description: 'run', parameters: { type: 'object' } },
|
||||
{ name: 'mcp__github__get_issue', description: 'm', parameters: { type: 'object' } },
|
||||
{ name: 'agent_teams_add_member', description: 't', parameters: { type: 'object' } },
|
||||
],
|
||||
},
|
||||
reason: 'initial',
|
||||
} as never)
|
||||
const headers = ctx.sessionProjections.snapshot(session).values.contextHeaders
|
||||
assert.ok(headers !== undefined, 'contextHeaders served after real appends')
|
||||
const tools = headers.headers[0].tools
|
||||
assert.equal(tools.find(t => t.name === 'bash')?.plugin, '@deepseek-ai/dsh-tool-bash')
|
||||
assert.equal(tools.find(t => t.name === 'mcp__github__get_issue')?.plugin, 'mcp:github')
|
||||
assert.ok(!('plugin' in tools.find(t => t.name === 'agent_teams_add_member')!), 'unmapped third-party tools stay untagged')
|
||||
})
|
||||
|
||||
test('register-hook attribution flows through to the rendered headers', async () => {
|
||||
const { ctx } = await boot()
|
||||
// dsh-context booted before any tools service existed; a late-provided
|
||||
// instance is wrapped on first read inside the registering plugin.
|
||||
ctx.provide('tools', { register() { return () => {} } })
|
||||
await ctx.plugin({
|
||||
name: 'my-agent-tools',
|
||||
apply(agentCtx) {
|
||||
const tools = (agentCtx as any).tools
|
||||
tools.register({ name: 'custom_dynamic_tool', description: 'd', parameters: { type: 'object' } })
|
||||
},
|
||||
})
|
||||
const session = ctx.sessions.create()
|
||||
session.append('request/header', {
|
||||
header: {
|
||||
config: { model: 'deepseek-v4-flash', provider: 'deepseek' },
|
||||
tools: [
|
||||
{ name: 'custom_dynamic_tool', description: 'd', parameters: { type: 'object' } },
|
||||
{ name: 'bash', description: 'run', parameters: { type: 'object' } },
|
||||
],
|
||||
},
|
||||
reason: 'initial',
|
||||
} as never)
|
||||
const headers = ctx.sessionProjections.snapshot(session).values.contextHeaders
|
||||
assert.ok(headers !== undefined, 'contextHeaders served after real appends')
|
||||
const tools = headers.headers[0].tools
|
||||
assert.equal(tools.find(t => t.name === 'custom_dynamic_tool')?.plugin, 'my-agent-tools')
|
||||
assert.equal(tools.find(t => t.name === 'bash')?.plugin, '@deepseek-ai/dsh-tool-bash', 'static backbone still applies')
|
||||
})
|
||||
|
||||
test('disposing the plugin fiber removes both keys from later snapshots', async () => {
|
||||
const { ctx, fiber } = await boot()
|
||||
const session = ctx.sessions.create()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Unit tests for host/toolSources.ts — best-effort tool-to-plugin attribution:
|
||||
// MCP server recovery from the `mcp__` name prefix, and the pinned first-party
|
||||
// package map. (A harness-logged `plugin` field, when one appears, passes
|
||||
// through headers.ts ahead of everything here.)
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import {
|
||||
FIRST_PARTY_SOURCES,
|
||||
MCP_PREFIX,
|
||||
mcpServerOf,
|
||||
mcpSourceOf,
|
||||
} from '../../src/host/toolSources'
|
||||
|
||||
describe('mcpServerOf', () => {
|
||||
test('recovers the server from a plain mcp__server__tool name', () => {
|
||||
assert.equal(mcpServerOf('mcp__github__get_issue'), 'github')
|
||||
assert.equal(mcpServerOf('mcp__my_server__send_message'), 'my_server')
|
||||
assert.equal(mcpServerOf('mcp__a__b__c'), 'a__b', 'server names may themselves contain __')
|
||||
})
|
||||
|
||||
test('non-MCP names and malformed prefixes resolve to undefined', () => {
|
||||
assert.equal(mcpServerOf('bash'), undefined)
|
||||
assert.equal(mcpServerOf('mcp_server_tool'), undefined, 'no mcp__ prefix')
|
||||
assert.equal(mcpServerOf('mcp__alone'), undefined, 'the prefix separator is not a server separator')
|
||||
assert.equal(mcpServerOf('mcp____'), undefined, 'a second separator right at the prefix edge')
|
||||
assert.equal(mcpServerOf('mcp____tool'), undefined, 'an empty server between separators')
|
||||
})
|
||||
|
||||
test('hash-appended overlong names resolve the server that survived truncation', () => {
|
||||
const hex64 = '0123456789abcdef'.repeat(4)
|
||||
// Overlong names get '_<64-hex>' appended; the last __ separator survives
|
||||
// unless the truncation cut into it.
|
||||
assert.equal(mcpServerOf(`mcp__github__tool_${hex64}`), 'github')
|
||||
assert.equal(mcpServerOf(`mcp__github__tool_${'z'.repeat(64)}`), 'github')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mcpSourceOf', () => {
|
||||
test('labels proxied tools with their server; other names stay unattributed', () => {
|
||||
assert.equal(mcpSourceOf('mcp__github__get_issue'), 'mcp:github')
|
||||
assert.equal(mcpSourceOf('mcp__alone'), undefined)
|
||||
assert.equal(mcpSourceOf('bash'), undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FIRST_PARTY_SOURCES', () => {
|
||||
test('the pinned map is frozen and carries the documented core names', () => {
|
||||
assert.ok(Object.isFrozen(FIRST_PARTY_SOURCES))
|
||||
for (const name of ['read', 'write', 'edit', 'bash', 'pwsh', 'glob', 'grep',
|
||||
'web_search', 'web_fetch', 'job_kill', 'ask_user_question', 'plan', 'skill',
|
||||
'todo_write', 'subagent', 'send_message', 'ralph', 'workflow', 'run_code']) {
|
||||
assert.ok(name in FIRST_PARTY_SOURCES, `expected ${name} in the first-party map`)
|
||||
}
|
||||
assert.equal(MCP_PREFIX, 'mcp__')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user