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:
1 parent
c2fb81eaaa
commit
a2641b7d29
4 files changed
+88
-2
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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': {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in new issue
Block a user