feat: AcFun tools plugin for DeepSeek Harness
Eight tools (search, video, comments, danmaku, rank, playurl, interact, post_comment) over acfun.cn's public web API, a model-facing skill, a bundle patch with a skill-filesystem row, and offline/live checks. Reads are anonymous; writes require allowWriteActions plus a login cookie.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Live post-install smoke test for the AcFun read tools.
|
||||
*
|
||||
* Exercises the same tool definitions the Harness registers, against the live
|
||||
* acfun.cn API, validates every canonical value against the tool's declared
|
||||
* output schema, and renders it. Write tools are expected to fail closed.
|
||||
*
|
||||
* Run with: node scripts/live-check.mjs
|
||||
*/
|
||||
|
||||
import { AcfunProvider } from '../lib/provider.js'
|
||||
import { resolveConfig } from '../lib/config.js'
|
||||
import { createAcfunTools } from '../lib/tools.js'
|
||||
import { validateValue } from './schema-check.mjs'
|
||||
|
||||
const provider = new AcfunProvider({ requestTimeoutMs: 30_000 })
|
||||
const config = resolveConfig({})
|
||||
const tools = new Map(createAcfunTools(provider, config).map((tool) => [tool.name, tool]))
|
||||
const exec = { signal: new AbortController().signal }
|
||||
const failures = []
|
||||
|
||||
/** Execute one tool, validate its canonical output and render it. */
|
||||
async function run(toolName, args, { expectError } = {}) {
|
||||
const tool = tools.get(toolName)
|
||||
if (tool === undefined) {
|
||||
failures.push(`${toolName}: not registered`)
|
||||
return undefined
|
||||
}
|
||||
console.log(`\n=== ${toolName} ${JSON.stringify(args)}`)
|
||||
const started = Date.now()
|
||||
let value
|
||||
try {
|
||||
value = await tool.execute(args, exec)
|
||||
} catch (error) {
|
||||
if (expectError !== undefined) {
|
||||
if (error?.code === expectError) {
|
||||
console.log(` ok failed closed with ${error.code}: ${error.message}`)
|
||||
return undefined
|
||||
}
|
||||
failures.push(`${toolName}: expected ${expectError}, got ${error?.code ?? error?.name}: ${error?.message}`)
|
||||
return undefined
|
||||
}
|
||||
failures.push(`${toolName}: threw ${error?.name}: ${error?.message}`)
|
||||
return undefined
|
||||
}
|
||||
if (expectError !== undefined) {
|
||||
failures.push(`${toolName}: expected ${expectError} but the call succeeded`)
|
||||
return value
|
||||
}
|
||||
const schemaErrors = validateValue(value, tool.output.schema)
|
||||
if (schemaErrors.length > 0) failures.push(`${toolName}: output schema violations: ${schemaErrors.join('; ')}`)
|
||||
else console.log(' ok canonical value matches output schema')
|
||||
let rendered
|
||||
try {
|
||||
rendered = tool.output.render(args, value)
|
||||
} catch (error) {
|
||||
failures.push(`${toolName}: render threw ${error?.message}`)
|
||||
return value
|
||||
}
|
||||
const text = rendered.map((block) => block.text).join('\n')
|
||||
console.log(` ok rendered ${text.length} chars in ${Date.now() - started} ms`)
|
||||
console.log(
|
||||
text
|
||||
.split('\n')
|
||||
.slice(0, 12)
|
||||
.map((line) => ` | ${line}`)
|
||||
.join('\n'),
|
||||
)
|
||||
return value
|
||||
}
|
||||
|
||||
const search = await run('acfun_search', { keyword: '原神' })
|
||||
await run('acfun_search', { keyword: '原神', sort: 'views' })
|
||||
const acId = search?.items?.[0]?.acId ?? '42588725'
|
||||
console.log(`\n(using acId ${acId} for the follow-up calls)`)
|
||||
|
||||
const video = await run('acfun_video', { acId })
|
||||
await run('acfun_comments', { acId, page: 1 })
|
||||
await run('acfun_danmaku', { acId, limit: 50 })
|
||||
await run('acfun_rank', { period: 'day', limit: 5 })
|
||||
await run('acfun_playurl', { acId })
|
||||
if (video !== undefined && video.title.length === 0) failures.push('acfun_video: empty title')
|
||||
|
||||
// Interactions must fail closed without allowWriteActions + cookie.
|
||||
await run('acfun_interact', { action: 'like', acId }, { expectError: 'WRITE_DISABLED' })
|
||||
await run('acfun_post_comment', { acId, content: 'live-check should not post this' }, { expectError: 'WRITE_DISABLED' })
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`\n${failures.length} live check(s) failed`)
|
||||
for (const failure of failures) console.error(`- ${failure}`)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log('\nall live read checks passed; write tools failed closed as designed')
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Minimal validator for the JSON Schema subset dsh-tools enforces:
|
||||
* type / properties / required / additionalProperties / items / enum / const /
|
||||
* oneOf, plus annotations. Used by the pre-install checks so a schema mistake
|
||||
* fails locally instead of at plugin activation.
|
||||
*/
|
||||
|
||||
const ANNOTATIONS = new Set(['description', 'title', 'default', 'examples'])
|
||||
const CONSTRAINTS = new Set(['type', 'oneOf', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const'])
|
||||
const TYPES = new Set(['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'])
|
||||
|
||||
/** Check that one schema node stays inside the enforced keyword subset. */
|
||||
export function assertSchemaSupported(schema, path = '$') {
|
||||
const errors = []
|
||||
if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
|
||||
return [`${path}: schema node must be an object`]
|
||||
}
|
||||
for (const key of Object.keys(schema)) {
|
||||
if (!CONSTRAINTS.has(key) && !ANNOTATIONS.has(key)) errors.push(`${path}: unsupported keyword "${key}"`)
|
||||
}
|
||||
if (schema.type !== undefined && !TYPES.has(schema.type)) errors.push(`${path}: unsupported type "${schema.type}"`)
|
||||
if (schema.enum !== undefined && !Array.isArray(schema.enum)) errors.push(`${path}.enum: must be an array`)
|
||||
if (schema.oneOf !== undefined) {
|
||||
if (!Array.isArray(schema.oneOf) || schema.oneOf.length < 2) errors.push(`${path}.oneOf: needs at least two branches`)
|
||||
else schema.oneOf.forEach((branch, index) => errors.push(...assertSchemaSupported(branch, `${path}.oneOf[${index}]`)))
|
||||
}
|
||||
if (schema.type === 'object') {
|
||||
if (schema.additionalProperties === undefined) errors.push(`${path}: object schema must declare additionalProperties`)
|
||||
else if (typeof schema.additionalProperties !== 'boolean') errors.push(`${path}.additionalProperties: must be boolean`)
|
||||
if (schema.properties !== undefined) {
|
||||
if (typeof schema.properties !== 'object' || schema.properties === null) errors.push(`${path}.properties: must be an object`)
|
||||
else {
|
||||
for (const [key, child] of Object.entries(schema.properties)) {
|
||||
errors.push(...assertSchemaSupported(child, `${path}.properties.${key}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (schema.required !== undefined) {
|
||||
if (!Array.isArray(schema.required)) errors.push(`${path}.required: must be an array`)
|
||||
else {
|
||||
const declared = schema.properties ?? {}
|
||||
for (const name of schema.required) {
|
||||
if (typeof name !== 'string') errors.push(`${path}.required: entries must be strings`)
|
||||
else if (!(name in declared)) errors.push(`${path}.required: "${name}" is not declared in properties`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (schema.type === 'array' && schema.items !== undefined) {
|
||||
errors.push(...assertSchemaSupported(schema.items, `${path}.items`))
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
/** Whether a JSON value matches one scalar type name. */
|
||||
function matchesType(value, type) {
|
||||
switch (type) {
|
||||
case 'object':
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
case 'array':
|
||||
return Array.isArray(value)
|
||||
case 'string':
|
||||
return typeof value === 'string'
|
||||
case 'number':
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
case 'integer':
|
||||
return typeof value === 'number' && Number.isInteger(value)
|
||||
case 'boolean':
|
||||
return typeof value === 'boolean'
|
||||
case 'null':
|
||||
return value === null
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one value against the schema subset; returns path-qualified errors. */
|
||||
export function validateValue(value, schema, path = '$') {
|
||||
const errors = []
|
||||
if (schema === null || typeof schema !== 'object') return [`${path}: invalid schema`]
|
||||
if (schema.oneOf !== undefined) {
|
||||
const branchResults = schema.oneOf.map((branch) => validateValue(value, branch, path))
|
||||
const matching = branchResults.filter((result) => result.length === 0).length
|
||||
if (matching !== 1) errors.push(`${path}: value must match exactly one oneOf branch (matched ${matching})`)
|
||||
return errors
|
||||
}
|
||||
if (schema.const !== undefined && value !== schema.const) errors.push(`${path}: must equal ${JSON.stringify(schema.const)}`)
|
||||
if (schema.enum !== undefined && !schema.enum.includes(value)) {
|
||||
errors.push(`${path}: must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join(', ')}`)
|
||||
}
|
||||
if (schema.type !== undefined && !matchesType(value, schema.type)) {
|
||||
errors.push(`${path}: expected ${schema.type}, got ${value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value}`)
|
||||
return errors
|
||||
}
|
||||
if (schema.type === 'object') {
|
||||
const properties = schema.properties ?? {}
|
||||
for (const name of schema.required ?? []) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, name)) errors.push(`${path}.${name}: missing required property`)
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (Object.prototype.hasOwnProperty.call(properties, key)) {
|
||||
errors.push(...validateValue(child, properties[key], `${path}.${key}`))
|
||||
} else if (schema.additionalProperties === false) {
|
||||
errors.push(`${path}.${key}: unexpected property (additionalProperties: false)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (schema.type === 'array') {
|
||||
const items = schema.items
|
||||
if (items !== undefined) value.forEach((entry, index) => errors.push(...validateValue(entry, items, `${path}[${index}]`)))
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
/** Build a minimal conforming sample value for schema-rendering checks. */
|
||||
export function sampleValue(schema) {
|
||||
if (schema.oneOf !== undefined) return sampleValue(schema.oneOf[0])
|
||||
if (schema.const !== undefined) return schema.const
|
||||
if (schema.enum !== undefined) return schema.enum[0]
|
||||
switch (schema.type) {
|
||||
case 'object': {
|
||||
const out = {}
|
||||
for (const [key, child] of Object.entries(schema.properties ?? {})) out[key] = sampleValue(child)
|
||||
return out
|
||||
}
|
||||
case 'array':
|
||||
return []
|
||||
case 'string':
|
||||
return 'sample'
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return 0
|
||||
case 'boolean':
|
||||
return false
|
||||
case 'null':
|
||||
return null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Offline pre-install check: load the bundle exactly as the Loader would
|
||||
* (`apply()` on a mock context), verify every tool definition, render each one
|
||||
* from a schema-shaped sample, and prove the write guard fails closed.
|
||||
*
|
||||
* Run with: node scripts/self-check.mjs
|
||||
*/
|
||||
|
||||
import { apply, inject, name } from '../index.js'
|
||||
import { resolveConfig } from '../lib/config.js'
|
||||
import { createAcfunTools, ACFUN_GUIDANCE } from '../lib/tools.js'
|
||||
import { AcfunProvider } from '../lib/provider.js'
|
||||
import { assertSchemaSupported, sampleValue, validateValue } from './schema-check.mjs'
|
||||
|
||||
const failures = []
|
||||
const check = (label, condition, detail = '') => {
|
||||
if (condition) {
|
||||
console.log(` ok ${label}`)
|
||||
} else {
|
||||
failures.push(`${label}${detail ? `: ${detail}` : ''}`)
|
||||
console.log(` FAIL ${label}${detail ? `: ${detail}` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('plugin exports')
|
||||
check('name is dsh-plugin-acfun', name === 'dsh-plugin-acfun', String(name))
|
||||
check('inject lists tools and systemPrompt', inject.includes('tools') && inject.includes('systemPrompt'), JSON.stringify(inject))
|
||||
check('guidance is non-empty prose', typeof ACFUN_GUIDANCE === 'string' && ACFUN_GUIDANCE.length > 80, String(ACFUN_GUIDANCE.length))
|
||||
|
||||
console.log('\nmock activation')
|
||||
const registered = []
|
||||
const sections = []
|
||||
const effects = []
|
||||
const mockCtx = {
|
||||
effect(factory) {
|
||||
effects.push(factory)
|
||||
factory()
|
||||
},
|
||||
tools: {
|
||||
register(definition) {
|
||||
registered.push(definition)
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
systemPrompt: {
|
||||
section(section) {
|
||||
sections.push(section)
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
}
|
||||
apply(mockCtx, {})
|
||||
|
||||
check('one system-prompt section registered', sections.length === 1, JSON.stringify(sections))
|
||||
check('section name is tool:acfun', sections[0]?.name === 'tool:acfun', String(sections[0]?.name))
|
||||
check('eight tools registered', registered.length === 8, String(registered.length))
|
||||
check(
|
||||
'tool names are unique acfun_* names',
|
||||
new Set(registered.map((tool) => tool.name)).size === 8 && registered.every((tool) => tool.name.startsWith('acfun_')),
|
||||
registered.map((tool) => tool.name).join(','),
|
||||
)
|
||||
|
||||
console.log('\ntool definitions')
|
||||
for (const tool of registered) {
|
||||
check(`${tool.name}: has a description`, typeof tool.description === 'string' && tool.description.length > 40)
|
||||
check(`${tool.name}: parameters stay in the schema subset`, assertSchemaSupported(tool.parameters, `${tool.name}.parameters`).length === 0, assertSchemaSupported(tool.parameters, `${tool.name}.parameters`).join('; '))
|
||||
check(`${tool.name}: parameters are an object root`, tool.parameters.type === 'object' && tool.parameters.additionalProperties === false)
|
||||
const outputErrors = assertSchemaSupported(tool.output.schema, `${tool.name}.output`)
|
||||
check(`${tool.name}: output stays in the schema subset`, outputErrors.length === 0, outputErrors.join('; '))
|
||||
check(`${tool.name}: output is an object root`, tool.output.schema.type === 'object')
|
||||
check(`${tool.name}: execute is a function`, typeof tool.execute === 'function')
|
||||
|
||||
const sample = sampleValue(tool.output.schema)
|
||||
const valueErrors = validateValue(sample, tool.output.schema, `${tool.name}.sample`)
|
||||
check(`${tool.name}: schema sample validates`, valueErrors.length === 0, valueErrors.join('; '))
|
||||
let rendered
|
||||
try {
|
||||
rendered = tool.output.render({}, sample)
|
||||
} catch (error) {
|
||||
check(`${tool.name}: render does not throw`, false, String(error?.message ?? error))
|
||||
continue
|
||||
}
|
||||
check(
|
||||
`${tool.name}: render returns text content blocks`,
|
||||
Array.isArray(rendered) && rendered.length > 0 && rendered.every((block) => block?.type === 'text' && typeof block.text === 'string'),
|
||||
)
|
||||
check(`${tool.name}: render uses its value`, rendered.some((block) => block.text.includes('sample')))
|
||||
}
|
||||
|
||||
console.log('\nwrite guard fails closed')
|
||||
const provider = new AcfunProvider({})
|
||||
const tools = createAcfunTools(provider, resolveConfig({}))
|
||||
const interact = tools.find((tool) => tool.name === 'acfun_interact')
|
||||
const postComment = tools.find((tool) => tool.name === 'acfun_post_comment')
|
||||
const exec = { signal: new AbortController().signal }
|
||||
|
||||
for (const [tool, args] of [
|
||||
[interact, { action: 'like', acId: '42588725' }],
|
||||
[postComment, { action: 'post', acId: '42588725', content: 'hello' }],
|
||||
]) {
|
||||
try {
|
||||
await tool.execute(args, exec)
|
||||
check(`${tool.name}: refuses to write without allowWriteActions`, false, 'execute resolved unexpectedly')
|
||||
} catch (error) {
|
||||
check(`${tool.name}: refuses to write without allowWriteActions`, error?.code === 'WRITE_DISABLED', String(error?.code))
|
||||
}
|
||||
}
|
||||
|
||||
const enabledProvider = new AcfunProvider({ allowWriteActions: true })
|
||||
const enabledTools = createAcfunTools(enabledProvider, resolveConfig({ allowWriteActions: true }))
|
||||
try {
|
||||
await enabledTools.find((tool) => tool.name === 'acfun_interact').execute({ action: 'follow', userId: '1' }, exec)
|
||||
check('write tools require a cookie even when enabled', false, 'execute resolved unexpectedly')
|
||||
} catch (error) {
|
||||
check('write tools require a cookie even when enabled', error?.code === 'LOGIN_REQUIRED', String(error?.code))
|
||||
}
|
||||
|
||||
console.log('\nconfig resolution')
|
||||
const withEnv = resolveConfig({}, { ACFUN_COOKIE: 'auth_key=abc; _did=xyz' })
|
||||
check('env cookie is read through cookieEnv', withEnv.cookie === 'auth_key=abc; _did=xyz' && withEnv.cookieSource === 'env')
|
||||
const literal = resolveConfig({ cookie: 'auth_key=literal', cookieEnv: 'OTHER' }, { OTHER: 'ignored' })
|
||||
check('literal cookie wins over env', literal.cookie === 'auth_key=literal' && literal.cookieSource === 'config')
|
||||
const defaults = resolveConfig(undefined)
|
||||
check('defaults keep writes disabled', defaults.allowWriteActions === false && defaults.cookie === '')
|
||||
check('bad config values fall back with warnings', resolveConfig(null).warnings.length > 0)
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`\n${failures.length} check(s) failed`)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log('\nall offline checks passed')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user