feat(host): surface skill-tool loads as tagged tool results + Skill inject event

A skill load via the `skill` tool returns the loaded skill's instructions
as a tool result. Keep it a tool result (that is what it is), but make it
findable: tag the node with the skill name (so the Context browser labels the
row `Skill · <name>`) and record an inject event so a `Skill 注入(name)`
entry shows in the Context Events card.

- fold: detect tool/result with node.tool === 'skill', parse the skill name
  from the rendered <skill_content name="…"> wrapper, set node.skill, emit a
  sub:'skill' inject event. Category stays 'tool'.
- browser: tool row shows the Skill · <name> tag when node.skill is set.
- i18n: add node.skillTag (zh/en).
- test: regression for the skill-tool result path.
This commit is contained in:
tangjunyan.5703 committed 2026-08-23 18:45:35 +08:00
1 parent c2fb81eaaa
commit a2641b7d29
4 files changed
+88 -2

No files matched your search

+3 -1
View File
@@ -783,7 +783,9 @@ export function makeContextBrowser(
let tag: string | null = null
let preview = nodeText(n)
if (n.cat === 'tool') {
tag = (n.tool ?? '?') + (n.err ? ' ⚠' : '')
// A `skill`-tool result is a loaded skill: label it by NAME (not the
// generic tool name) so it scans apart from ordinary tool results.
tag = n.skill ? t('node.skillTag', { name: n.skill }) : (n.tool ?? '?') + (n.err ? ' ⚠' : '')
// A call that summarizes itself (bash's `description`, the path
// of an edit/read call) previews with that line in the collapsed
// row; the generic result label only when the call says nothing.
+2
View File
@@ -72,6 +72,7 @@ export const DICT_ZH: Record<string, string> = {
'node.empty': '(空回复)',
'node.nonText': '(非文本消息)',
'node.snapshot': '快照: ',
'node.skillTag': 'Skill · {name}',
'cmd.desc': '查看当前上下文构成,浏览各步骤组成',
'cmd.close': '关闭',
'browser.title': '上下文浏览器',
@@ -189,6 +190,7 @@ export const DICT_EN: Record<string, string> = {
'node.empty': '(empty reply)',
'node.nonText': '(non-text message)',
'node.snapshot': 'Snapshot: ',
'node.skillTag': 'Skill · {name}',
'cmd.desc': 'View current context makeup, browse per-step composition',
'cmd.close': 'Close',
'browser.title': 'Context Browser',
+45 -1
View File
@@ -242,6 +242,35 @@ interface MessageLike {
error?: boolean
}
/**
* The first full text block, recursing through nested content blocks (a tool
* result wraps its text in a `tool-result` block). Unlike `firstText` this
* must NOT truncate/normalize: the skill name is matched off the raw
* `<skill_content name="…">` wrapper.
*/
function nestedText(blocks: ContentBlock[] | undefined): string {
if (!Array.isArray(blocks)) return ''
for (const block of blocks) {
if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') return block.text
if (block.content !== undefined) {
const nested = nestedText(block.content)
if (nested !== '') return nested
}
}
return ''
}
/**
* The skill name a `skill`-tool result carries. Loaded skills are rendered as
* `<skill_content name="…">…</skill_content>` in the result's text, so the name
* is recovered from the content rather than trusted from the call envelope.
*/
function skillNameOf(msg: MessageLike | null | undefined): string {
const text = nestedText(msg?.content)
const match = text.match(/<skill_content\s+name="([^"]+)"/)
return match === null ? '' : match[1]
}
function applySurface(
st: TimelineState,
ev: SurfaceEventLike,
@@ -520,7 +549,22 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
// the envelope would miss all content).
const toolMsg = deriveEventMessage(event as never) as MessageLike | null
const s = ensure()
applySurface(s, event, event.type, data, toolMsg)
const node = applySurface(s, event, event.type, data, toolMsg)
// A skill load via the `skill` tool returns the loaded skill's
// instructions as a tool result — content the harness injected into the
// model's context. Keep it a tool result (that is what it is), but make
// it findable: tag the node with the skill name so the browser can label
// the row, and record an inject event so a `Skill 注入(name)` entry
// shows in the Context Events card instead of being buried among
// ordinary tool results. `node.tool` resolves to the tool name `skill`;
// the skill NAME comes from the rendered `<skill_content name="…">`.
if (node.cat === 'tool' && node.tool === 'skill') {
const name = skillNameOf(toolMsg)
if (name !== '') {
node.skill = name
s.events.push({ seq: event.seq, time: event.time, kind: 'inject', form: 'instructions', sub: 'skill', name, tokens: node.tokens })
}
}
break
}
case 'assistant/message': {
+38
View File
@@ -517,3 +517,41 @@ test('host HMR safety: fiber dispose removes both projection registrations', ()
for (const remove of removers) remove() // simulate fiber dispose
assert.equal(defs.size, 0, 'fiber dispose removes every registration')
})
test('host: a skill-tool result is tagged and surfaced as a Skill injection while staying a tool result', () => {
const defs = new Map()
const fakeCtx = {
inject(list, cb) { cb(this) },
effect() { return () => {} },
sessionProjections: { register(d) { defs.set(d.key, d); return () => {} } },
}
apply(fakeCtx)
const def = defs.get('contextTimeline')
const drive = (events) => {
let st = def.init()
for (const ev of events) st = def.apply(st, ev)
return def.view(st)
}
const events = [
{ seq: 1, type: 'request/header', time: 1000, data: { header: { system: 's', tools: [{ name: 'skill' }], config: { model: 'deepseek-v4' } } } },
{ seq: 2, type: 'tool/call', time: 4000, data: { callId: 'c1', name: 'skill', arguments: '{"name":"code-review"}' } },
{ seq: 3, type: 'tool/result', time: 4100, data: { callId: 'c1', message: {
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: '<skill_content name="code-review">\n<skill_instructions>Run a thorough review.</skill_instructions>\n</skill_content>' }] }],
} } },
{ seq: 4, type: 'assistant/message', time: 5000, data: { turn: 1, step: 1, usage: { inputTokens: 10 }, message: { content: [{ type: 'text', text: 'ok' }] } } },
]
const v = drive(events)
const node = v.nodes.find(n => n.seq === 3)
assert.equal(node.cat, 'tool', 'skill tool result stays a tool node (skill is a tool)')
assert.equal(node.tool, 'skill', 'node names the skill tool')
assert.equal(node.skill, 'code-review', 'node is tagged with the loaded skill name')
const inject = v.events.find(e => e.kind === 'inject')
assert.ok(inject, 'skill load emits an inject event (so it lists in Context Events)')
assert.equal(inject.sub, 'skill', 'event sub-carries the skill kind')
assert.equal(inject.name, 'code-review', 'event names the loaded skill')
assert.equal(inject.form, 'instructions', 'event uses the instructions form')
assert.ok(inject.tokens > 0, 'event carries the skill content token cost')
assert.equal(inject.turn, 1, 'inject attributed to the turn that consumed it')
assert.equal(inject.step, 1, 'inject attributed to the step that consumed it')
})