feat: agents and skills

This commit is contained in:
Gab 2026-03-24 17:42:26 +11:00
parent 485cc7649e
commit ff77a81141
10 changed files with 305 additions and 13 deletions

View File

@ -50,8 +50,9 @@ def classify_tool(func: AgentFunction) -> ToolType:
"""
Classify a tool based on its properties.
Currently the SDK exposes:
- agent_functions: API functions with request_type
Types:
- AGENT_SKILL: is_agent_skill=True
- API_FUNCTION: has request_type
Args:
func: AgentFunction from TF SDK
@ -59,6 +60,10 @@ def classify_tool(func: AgentFunction) -> ToolType:
Returns:
ToolType enum value
"""
# Agent skills have is_agent_skill=True
if getattr(func, 'is_agent_skill', None) is True:
return ToolType.AGENT_SKILL
# All agent_functions with request_type are API Functions
if func.request_type:
return ToolType.API_FUNCTION
@ -89,6 +94,10 @@ def parse_function(func: AgentFunction) -> SyncedTool:
# or may use TF proxy
auth_via = "user_provided" if func.authorisation_type == "api_key" else "tf_proxy"
# Agent skills use skill script
if tool_type == ToolType.AGENT_SKILL:
auth_via = "tf_skill"
return SyncedTool(
id=func.id,
name=func.name,
@ -98,6 +107,7 @@ def parse_function(func: AgentFunction) -> SyncedTool:
url=func.url,
authorisation_type=func.authorisation_type,
auth_via=auth_via,
is_agent_skill=tool_type == ToolType.AGENT_SKILL,
)
@ -126,7 +136,8 @@ def sync_tools(config: TFConfig) -> ToolSyncResult:
Sync all tools from ToothFairyAI workspace using SDK.
Includes:
- Agent Functions (API Functions)
- Agent Functions (API Functions with request_type)
- Agent Skills (functions with is_agent_skill=True)
- Coder Agents (agents with mode='coder')
Args:
@ -138,7 +149,7 @@ def sync_tools(config: TFConfig) -> ToolSyncResult:
try:
client = config.get_client()
# Sync agent functions
# Sync agent functions (includes API Functions and Agent Skills)
func_result = client.agent_functions.list()
tools = [parse_function(f) for f in func_result.items]

View File

@ -17,6 +17,7 @@ import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogChangelog } from "./ui/dialog-changelog"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
@ -576,11 +577,22 @@ function App() {
},
category: "System",
},
{
title: "Changelog",
value: "changelog.show",
slash: {
name: "changelog",
},
onSelect: () => {
dialog.replace(() => <DialogChangelog />)
},
category: "System",
},
{
title: "Open docs",
value: "docs.open",
onSelect: () => {
open("https://opencode.ai/docs").catch(() => {})
open("https://toothfairyai.com/developers/tfcode").catch(() => {})
dialog.clear()
},
category: "System",

View File

@ -15,12 +15,13 @@ import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
"opencode-go": 1,
openai: 2,
"github-copilot": 3,
anthropic: 4,
google: 5,
toothfairyai: 0,
opencode: 1,
"opencode-go": 2,
openai: 3,
"github-copilot": 4,
anthropic: 5,
google: 6,
}
export function createDialogProviderOptions() {
@ -36,6 +37,7 @@ export function createDialogProviderOptions() {
title: provider.name,
value: provider.id,
description: {
toothfairyai: "(Recommended - ToothFairyAI)",
opencode: "(Recommended)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
@ -237,6 +239,19 @@ function ApiMethod(props: ApiMethodProps) {
placeholder="API key"
description={
{
toothfairyai: (
<box gap={1}>
<text fg={theme.textMuted}>
ToothFairyAI gives you access to AI coding models through your workspace credentials.
</text>
<text fg={theme.text}>
Set credentials via <span style={{ fg: theme.primary }}>tfcode setup</span> or environment variables
</text>
<text fg={theme.textMuted}>
TF_WORKSPACE_ID, TF_API_KEY, TF_REGION (dev/au/eu/us)
</text>
</box>
),
opencode: (
<box gap={1}>
<text fg={theme.textMuted}>

View File

@ -67,7 +67,7 @@ const TIPS = [
"Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown",
"Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard",
"Press {highlight}Ctrl+P{/highlight} to see all available actions and commands",
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers",
"Run {highlight}/connect{/highlight} to configure your ToothFairyAI provider",
"The leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions",
"Press {highlight}F2{/highlight} to quickly switch between recently used models",
"Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel",

View File

@ -0,0 +1,74 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "@tui/context/theme"
import { useDialog } from "./dialog"
import { useKeyboard } from "@opentui/solid"
import { For } from "solid-js"
interface ChangelogEntry {
version: string
date: string
changes: string[]
}
const CHANGELOG: ChangelogEntry[] = [
{
version: "1.0.0-beta",
date: "2026-03-24",
changes: [
"Initial tfcode release based on opencode",
"Custom TF CODE logo with teal branding",
"ToothFairyAI workspace integration",
"Sync tools from ToothFairyAI workspace",
"TF coder agents appear in /agents",
"Commands: tfcode sync, tfcode validate, tfcode tools list",
"Support for dev, au, eu, us regions",
],
},
]
export function DialogChangelog() {
const dialog = useDialog()
const { theme } = useTheme()
useKeyboard((evt) => {
if (evt.name === "return" || evt.name === "escape") {
dialog.clear()
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
tfcode Changelog
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc/enter
</text>
</box>
<box flexDirection="column" gap={1}>
<For each={CHANGELOG}>
{(release) => (
<box flexDirection="column" gap={0}>
<text fg={theme.primary} attributes={TextAttributes.BOLD}>
v{release.version} ({release.date})
</text>
<For each={release.changes}>
{(change) => (
<text fg={theme.textMuted}>
{" "} {change}
</text>
)}
</For>
</box>
)}
</For>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={() => dialog.clear()}>
<text fg={theme.selectedListItemText}>ok</text>
</box>
</box>
</box>
)
}

View File

@ -15,6 +15,13 @@ export namespace ModelsDev {
const log = Log.create({ service: "models.dev" })
const filepath = path.join(Global.Path.cache, "models.json")
const REGION_URLS: Record<string, string> = {
dev: "https://ai.toothfairylab.link",
au: "https://ai.toothfairyai.com",
eu: "https://ai.eu.toothfairyai.com",
us: "https://ai.us.toothfairyai.com",
}
export const Model = z.object({
id: z.string(),
name: z.string(),
@ -81,6 +88,21 @@ export namespace ModelsDev {
export type Provider = z.infer<typeof Provider>
interface TFModel {
name: string
provider: string
modelType: string
reasoner: boolean
supportsVision: boolean
toolCalling: boolean
maxTokens: number
deprecated: boolean
pricing?: {
inputPer1mTokens: number
outputPer1mTokens: number
}
}
function url() {
return Flag.OPENCODE_MODELS_URL || "https://models.dev"
}
@ -100,7 +122,122 @@ export namespace ModelsDev {
export async function get() {
const result = await Data()
return result as Record<string, Provider>
const providers = result as Record<string, Provider>
// Try to fetch ToothFairyAI models dynamically
// First check env vars, then stored credentials
let tfApiKey = process.env.TF_API_KEY
let tfRegion = process.env.TF_REGION || "au"
// Try to load from stored credentials
if (!tfApiKey) {
try {
const credPath = path.join(Global.Path.data, ".tfcode", "credentials.json")
const credData = await Bun.file(credPath).json() as { api_key?: string; region?: string }
if (credData.api_key) {
tfApiKey = credData.api_key
tfRegion = credData.region || "au"
}
} catch {}
}
const tfBaseUrl = REGION_URLS[tfRegion] || REGION_URLS.au
if (tfApiKey) {
try {
const tfResponse = await fetch(`${tfBaseUrl}/models_list`, {
headers: {
"x-api-key": tfApiKey,
},
signal: AbortSignal.timeout(10000),
})
if (tfResponse.ok) {
const tfData = await tfResponse.json() as { templates: Record<string, TFModel> }
const tfModels: Record<string, Model> = {}
for (const [key, model] of Object.entries(tfData.templates || {})) {
if (model.deprecated) continue
const modelId = key.startsWith("z/") ? key.slice(2) : key
tfModels[modelId] = {
id: modelId,
name: model.name,
family: model.provider,
release_date: new Date().toISOString().split("T")[0],
attachment: model.supportsVision || false,
reasoning: model.reasoner || false,
temperature: true,
tool_call: model.toolCalling || false,
options: {},
cost: {
input: model.pricing?.inputPer1mTokens || 0,
output: model.pricing?.outputPer1mTokens || 0,
},
limit: {
context: model.maxTokens ? model.maxTokens * 4 : 128000,
output: model.maxTokens || 16000,
},
modalities: {
input: model.supportsVision ? ["text", "image"] : ["text"],
output: ["text"],
},
}
}
providers["toothfairyai"] = {
id: "toothfairyai",
name: "ToothFairyAI",
env: ["TF_API_KEY", "TF_WORKSPACE_ID"],
models: tfModels,
}
}
} catch (e) {
log.error("Failed to fetch ToothFairyAI models", { error: e })
}
}
// Fallback to static models if dynamic fetch failed
if (!providers["toothfairyai"] || Object.keys(providers["toothfairyai"].models).length === 0) {
providers["toothfairyai"] = {
id: "toothfairyai",
name: "ToothFairyAI",
env: ["TF_API_KEY", "TF_WORKSPACE_ID"],
models: {
"sorcerer": {
id: "sorcerer",
name: "TF Sorcerer",
family: "groq",
release_date: "2025-01-01",
attachment: true,
reasoning: false,
temperature: true,
tool_call: true,
options: {},
cost: { input: 0.62, output: 1.75 },
limit: { context: 128000, output: 16000 },
modalities: { input: ["text", "image"], output: ["text"] },
},
"mystica": {
id: "mystica",
name: "TF Mystica",
family: "fireworks",
release_date: "2025-01-01",
attachment: true,
reasoning: false,
temperature: true,
tool_call: true,
options: {},
cost: { input: 2.5, output: 7.5 },
limit: { context: 128000, output: 16000 },
modalities: { input: ["text", "image"], output: ["text"] },
},
},
}
}
return providers
}
export async function refresh() {

View File

@ -182,6 +182,29 @@ export namespace Provider {
options: hasKey ? {} : { apiKey: "public" },
}
},
async toothfairyai(input) {
const hasCredentials = await (async () => {
const env = Env.all()
if (env.TF_API_KEY) return true
if (await Auth.get(input.id)) return true
const config = await Config.get()
if (config.provider?.["toothfairyai"]?.options?.apiKey) return true
// Check stored credentials file
try {
const credPath = path.join(Global.Path.data, ".tfcode", "credentials.json")
const credData = await Bun.file(credPath).json() as { api_key?: string }
if (credData.api_key) return true
} catch {}
return false
})()
return {
autoload: hasCredentials,
options: hasCredentials ? {} : { apiKey: "setup-required" },
}
},
openai: async () => {
return {
autoload: false,

View File

@ -12,6 +12,7 @@ export const ProviderID = providerIdSchema.pipe(
make: (id: string) => schema.makeUnsafe(id),
zod: z.string().pipe(z.custom<ProviderID>()),
// Well-known providers
toothfairyai: schema.makeUnsafe("toothfairyai"),
opencode: schema.makeUnsafe("opencode"),
anthropic: schema.makeUnsafe("anthropic"),
openai: schema.makeUnsafe("openai"),

View File

@ -0,0 +1,19 @@
import { Global } from "./src/global"
import path from "path"
import { ModelsDev } from "./src/provider/models"
console.log("Global.Path.data:", Global.Path.data)
const credPath = path.join(Global.Path.data, ".tfcode", "credentials.json")
console.log("Credentials path:", credPath)
try {
const credData = await Bun.file(credPath).json()
console.log("Credentials loaded:", { api_key: (credData as any).api_key?.slice(0,10)+"...", region: (credData as any).region })
} catch (e) {
console.log("Error loading credentials:", e)
}
const providers = await ModelsDev.get()
console.log("ToothFairyAI provider found:", !!providers["toothfairyai"])
console.log("Models count:", Object.keys(providers["toothfairyai"]?.models || {}).length)
console.log("Sample models:", Object.keys(providers["toothfairyai"]?.models || {}).slice(0, 5))