Systemprompt- & Toollisten-Aufbau — Hermes vs. Personal Agent¶
Fokussierter Architektur-Vergleich: Wie bauen beide Systeme den Systemprompt und die an das Modell übergebene Toolliste zusammen — und was sollten wir bei Personal Agent (PA) übernehmen.
Ergänzt die breiten Feature-Vergleiche in
personal-agent-vs-hermes-agent.mdundhermes-feature-adoption.mdum die konkreten Bau-Mechaniken. Quellen: geklonter StandNousResearch/hermes-agent(agent/system_prompt.py,agent/prompt_builder.py,toolsets.py,model_tools.py,tools/registry.py,tools/tool_search.py) und PA (agent/service.py,agent/instructions.py,agent/run_instructions.py,assembler/assembler.py,assembler/policy.py).
1. TL;DR — die drei Lehren¶
- Cache-bewusste Tier-Trennung des Prompts. Hermes teilt den Systemprompt
bewusst in
stable → context → volatile, damit der stabile Präfix provider-seitig gecacht werden kann. PA mischt heute stabile Identität und volatile Welt-/Memory-Daten in einen einzigeninstructions-String und baut ihn pro Turn neu — Prompt-Caching ist damit unmöglich. Das ist der wichtigste übernehmbare Punkt (Token-Kosten + Latenz, gerade bei langem System-Layering und durable Replays). - Progressive Tool-Disclosure (Tool Search). Hermes ersetzt große
MCP-/Plugin-Toollisten ab ~10 % Kontextfenster durch drei Bridge-Tools
(
tool_search/tool_describe/tool_call). PA hat keine Schema-Deferral: alle Tools sind immer im Array. Bei vielen MCP-Servern frisst das Kontext. - Modellfamilien-bedingte Tool-Use-Guidance. Hermes injiziert GPT/Codex/Gemini-spezifische „du MUSST Tools nutzen"-Blöcke nur für die schwächeren Tool-Caller. PA hat eine einheitliche Guidance — übernehmenswert, da PA „auto"-Modellwahl quer über Provider fährt.
Beim Rest ist PA gleichwertig oder reicher (Governance-Gates, Untrusted-Content-Policy, World-Memory-Push, Surfaces, durable RunSpec-Snapshot).
2. Systemprompt-Aufbau¶
2.1 Hermes — drei geordnete Tiers (agent/system_prompt.py)¶
build_system_prompt_parts() → build_system_prompt() joint drei Listen mit
\n\n, in fester Reihenfolge stable → context → volatile:
| Tier | Inhalt (Reihenfolge) | Caching |
|---|---|---|
| stable | 1. Identität (SOUL.md oder DEFAULT_AGENT_IDENTITY) · 2. Help-Guidance · 3. Task-Completion-Guidance · 4. Parallel-Tool-Call-Guidance · 5. tool-abhängige Verhaltensblöcke (memory/session_search/skills/kanban — nur wenn das Tool im Run ist) · 6. Steer-Channel-Note · 7. computer_use · 8. Nous-Subscription · 9. Tool-Use-Enforcement (modellfamilien-bedingt) · 10. Skills-Index · 11. Environment-Hints · 12. Coding-Context · 13. Platform-Hints |
gecacht — über die ganze Session byte-stabil, wird mid-session nie neu gerendert |
| context | caller-system_message + ein Projekt-Kontextfile (Priorität: .hermes.md → AGENTS.md → CLAUDE.md → .cursorrules) |
stabil pro Session |
| volatile | MEMORY.md-Snapshot · USER.md-Profil-Snapshot · externer Memory-Provider-Block · Zeit/Session/Model/Provider-Zeile (nur Datum, nicht Minute → Cache bleibt den Tag warm) |
nicht gecacht, Rebuild nur nach Kompression |
Schlüsselideen:
- Identität = austauschbar via
~/.hermes/SOUL.md; Fallback ist die hartkodierteDEFAULT_AGENT_IDENTITY. Bei Subagent-Delegation (skip_context_files) wird SOUL übersprungen. - Modellfamilien-Conditional (
system_prompt.py):"gemini"/"gemma"→GOOGLE_MODEL_OPERATIONAL_GUIDANCE;"gpt"/"codex"/"grok"→OPENAI_MODEL_EXECUTION_GUIDANCE. Der Enforcement-Block selbst wird per Gateagent._tool_use_enforcement(auto/bool/Liste) gesteuert;automatchtTOOL_USE_ENFORCEMENT_MODELS = (gpt, codex, gemini, gemma, grok, glm, qwen, deepseek). - Platform-Hints =
PLATFORM_HINTS-Dict (cli/telegram/whatsapp/discord/…), pro Plattform ausconfig.yamlperappend/replaceoverride-bar; defensiv fail-safe. - Skills-Index = kompakter
<available_skills>-Block (Kategorie → Name + Kurzbeschreibung), zweistufig gecacht (LRU + Disk-Snapshot), gefiltert nach Plattform/Tools, mit „Skills (mandatory): erst scannen, dannskill_view". - Memory kommt als Snapshot in den volatile Tier; mid-session-Writes ändern nur die Disk, nicht den bereits gebauten Prompt (bis Rebuild).
2.2 Personal Agent — ein Layer-Seam, ~20 Schichten (agent/service.py:_layer_instructions)¶
PA baut pro Run dynamisch einen einzigen instructions-String. Reihenfolge
(verkürzt, service.py:983+):
- Basis-Template —
derive_instructions(system_prompt, override)(agent/factory.py:54) - Folder-Wissen —
folder_system_prompt(...)(geteilt über Chats eines Ordners) - User-Context —
user_context_instruction(...)(agent/instructions.py:52):agent_name+agent_soul(= PAs „SOUL"), User-Name, Timezone, Adresse, Priorities,RESPOND_LANGUAGE(Deutsch default), comms-rules - Response-Style (Länge/Sprache) — inline-only
- Collab-Mode —
plan/execute/pair(_COLLAB_DIRECTIVES) - Skills-Preamble —
build_skills_preamble()(agent/skills.py:26):- {name}: {description} — When to use: {when_to_use}+ Self-Improve-Nudge - Workflows-Preamble — gespeicherte Workflows als
run_workflow(name=…)-Index - Delegation-Hint
- Surface-Instructions — per-Surface-Overlay (
surface_resolver.py), builtinstandard/codingoder custom; Coding-FallbackCODING_BEHAVIOR - Workspace-Projektregeln —
AGENTS.md/CLAUDE.mdaus dem Workspace-Repo (inline-only, via device_gateway) 11.–16. Nudges: Handoff-Kickoff, Ask-User, Entity-Cards (pa-card), Mermaid, Math, Kompressions-Summary - Known World State —
_known_world_state(...)(agent/world_context.py): Stage-1-„Push" — prompt-verlinkte Entities + aktive Facts + Tiefe-1-Relationen- Owner-
preference/policy+ max. 4 salient Events → kompakter## Known World State-Referenzblock. Pro Turn neu berechnet.
- Owner-
- Tag-Gate-Note — welche Integrationen/MCP von der Governance geblockt wurden 19.–22. Per-Turn-Direktiven: Title-Instruction, Main-Chat-Coding-Redirect, Goal-Instruction, Side-Query etc.
2.3 Direktvergleich Systemprompt¶
| Aspekt | Hermes | Personal Agent |
|---|---|---|
| Struktur | 3 explizite Tiers, fixe Reihenfolge | 1 String, ~20 bedingte Layer |
| Identität/Persona | SOUL.md (Datei) / Default-Konstante |
agent_name+agent_soul (User-Settings-Feld) |
| Projekt-Kontextfiles | .hermes.md/AGENTS.md/CLAUDE.md/.cursorrules (1 gewinnt) |
AGENTS.md/CLAUDE.md aus Workspace (inline-only) |
| Memory-Injektion | Snapshot im volatile Tier | Push-Referenzblock, pro Turn neu (reicher: Graph statt Flatfile) |
| Plattform-Hints | PLATFORM_HINTS + config.yaml-Override |
Surface-Overlay (DB/UUID), kein deklaratives Hint-Dict |
| Skills-Index | <available_skills>, 2-stufig gecacht |
Preamble-Liste, pro Turn gebaut |
| Modellfamilien-Conditional | ja (GPT/Codex/Gemini) | nein (einheitlich) |
| Prompt-Caching | bewusst designt (stable Präfix) | nicht vorhanden (cache_control/cache_point = 0 Treffer); pro Turn neu, volatile mit stabil vermischt |
| Governance-Transparenz im Prompt | — | ja (Tag-Gate-Note) — reicher als Hermes |
3. Toollisten-Aufbau¶
3.1 Hermes — datengetriebene Toolsets + Registry + Tool Search¶
- Toolsets als Daten (
toolsets.py):TOOLSETS-Dict mitcore(eine logische Gruppe),composite(includesanderer Toolsets) undplatform(hermes-cli,hermes-telegram, …).resolve_toolset()expandiert rekursiv (mit Zyklenschutz) zu konkreten Tool-Namen;all/*= alles außer opt-in (kanban) und capability-gated. - Registry (
tools/registry.py): jedes Tool-Modul ruft beim Importregistry.register(name, toolset, schema, handler, check_fn, requires_env, …).discover_builtin_tools()findet Module per AST-Scan automatisch — keine manuelle Liste. Schema = OpenAI-Function-Calling-Dict. - Runtime-Gating =
check_fnpro Tool (Bool, 30 s TTL-gecacht): Key vorhanden? Backend erreichbar (CDP)? Binary da? → schlägt es fehl, fällt das Tool ganz raus. Dynamisches Schema-Patching fürexecute_code/browser_*auf nur tatsächlich verfügbare Tools. - Tool Search (
tools/tool_search.py): abthreshold_pct(default 10 % des Kontextfensters) werden deferrable Tools (MCP + Nicht-Core-Plugins; nie_HERMES_CORE_TOOLS) aus dem Array entfernt und durchtool_search/tool_describe/tool_callersetzt. BM25 über Name+Beschreibung, Katalog pro Turn frisch aus der Live-Registry, auf die Session-Toolsets beschränkt.tool_callentpackt die Bridge und dispatcht das echte Tool (Hooks laufen gegen den echten Namen).
3.2 Personal Agent — ein Assembler, pydantic-ai-Toolsets, Governance-Gates¶
- Ein Orchestrator:
ToolsetAssembler.assemble()(assembler/assembler.py) sammelt: built-in First-Party-Toolsets (chat-admin, interaction/ask_user, todo, memory/recall, world-memory, commitments, notes, time, entity-action, scenes, workflows, phone, skills, comms-agent, entity-/document-RAG), Integrations-Toolsets (HA-Stil Config-Entries), Web-Tools (search + lokaler SSRF-geschützter fetch), Sub-Agents (delegate_to,create_plan,run_workflow), Geräte-Toolsets. - Tool-Definition:
pydantic_ai.toolsets.FunctionToolset[PersonalAgentDeps]mit@ts.tool-dekorierten async-Funktionen; Kontext viactx.deps. Schema wird von pydantic-ai aus der Signatur/Docstring abgeleitet (kein manuelles JSON-Dict wie Hermes). - Gating (
assembler/policy.py), mehrschichtig — reicher als Hermes: - Tier-Gate (
apply_tier_gate): Datenklassifikation,provider_tier ≥ TOOL_REQUIRED_TIER. - Untrusted-Content-Gate (
apply_untrusted_gate, Frozen Contract #13): wenn untrusted MCP/OpenAPI im Run, fallenHIGH_PRIVILEGE_TOOLS(+ Device-dev_<id>_*) raus; eine kleine Guard-exempt-Liste bleibt. - Tool-Deny-List aus dem Composer (
cfg["disabled_tools"]→toolset.filtered()). - Provider-Tag-Gate nach Modellauflösung (BLOCK-Mode).
- Snapshot (Frozen Contract #6): Toolsets werden bei Run-Start in
RunSpec.toolsets(ToolsetSnapshot: integrations, denied_tools, devices) eingefroren; der Workflow fragt nie Live-DB im Run/Replay. - Kein Schema-Deferral: alle Tools immer im Array; PAs
deferred.pyist pydantic-ais deferred-tool resume (ask_user-Karten), nicht Tool-Schema-Progressive-Disclosure.
3.3 Direktvergleich Tools¶
| Aspekt | Hermes | Personal Agent |
|---|---|---|
| Quelle der Toolliste | TOOLSETS-Dict + Auto-Discovery-Registry |
ToolsetAssembler.assemble() (Code) |
| Tool-Schema | manuelles OpenAI-JSON-Dict | aus pydantic-Signatur abgeleitet |
| Plattform-Presets | hermes-<platform>-Toolsets (Daten) |
Surface + Composer-Auswahl |
| Capability-Gating | check_fn (Key/Backend/Binary) |
Integration-Capability-Provider + Tier/Tag-Gates |
| Sicherheits-Gating | dangerous-command-Approval (Terminal) | Tier + Untrusted + Tag + Deny-List + Security-Mode (deutlich reicher) |
| Durable Snapshot | — (CLI/Session-State) | RunSpec/ToolsetSnapshot (Contract #6) |
| Schema-Deferral / Tool Search | ja (MCP/Plugin, ab 10 %) | nein |
4. Was wir übernehmen müssen / sollten¶
ADOPT — klare Empfehlung¶
A1. Cache-bewusste Prompt-Tier-Trennung (höchster Hebel).
Heute mischt _layer_instructions stabile Identität (User-Context, Skills-,
Workflow-Preamble, Surface, Nudges) mit volatilen Daten (Known World State,
Kompressions-Summary, Title-/Goal-/Side-Direktiven) in einen String, der pro
Turn neu gebaut und voll gesendet wird. Refactoren in geordnete Tiers:
stable— Basis-Template, Folder, User-Context (Identität/Persona/Sprache), Skills-Index, Workflow-Index, Delegation/Surface, statische Nudges (Cards/Mermaid/Math/Ask-User).volatile— Known World State, Kompressions-Summary, Per-Turn-Direktiven (Title/Goal/Side), Tag-Gate-Note, Zeitzeile.
Dann auf der Anthropic-/kompatiblen Schiene cache_control-Marker an die
Tier-Grenze setzen (stabiler Präfix). Genau wie Hermes: Zeitstempel auf
Datums-Granularität halten, damit der Cache den Tag überlebt. Nutzen:
spürbar weniger Input-Tokens & Latenz bei den langen PA-Prompts; greift inline
und im durable Pfad (Marker sind ModelSettings-/Message-Sache, kollidieren
nicht mit BYOK Contract #5 oder „keine Secrets in Inputs" #15). Voraussetzung:
World State NICHT mehr in den stabilen Teil mischen — das ist heute der
Haupt-Cache-Buster.
A2. Modellfamilien-bedingte Tool-Use-Guidance.
Da PA model=="auto" quer über Provider auflöst, lohnt ein kleiner
Conditional-Block analog TOOL_USE_ENFORCEMENT_MODELS: für schwächere
Tool-Caller (GPT/Gemini/Qwen/DeepSeek/GLM/…) den „du MUSST Tools wirklich
ausführen, nicht beschreiben"-Block injizieren; für Claude/starke Caller weglassen.
Hängt sauber an der bestehenden auto_model/resolver-Stufe (Provider-Tags
liegen dort schon vor). Gehört in den stable Tier (modellabhängig, aber pro
Run stabil).
ADAPT — übernehmen, an PA anpassen¶
A3. Tool Search / Schema-Deferral für MCP & Integrationen.
PA hat genau das Hermes-Problem latent: viele Integrationen + MCP-Server → große,
immer präsente Tool-Arrays. Eine auto-Deferral (Schwelle = % Kontextfenster)
analog tools/tool_search.py, die First-Party-Core-Tools nie defert (wie
_HERMES_CORE_TOOLS), sondern nur Integrations-/MCP-Tools hinter
tool_search/tool_describe/tool_call legt. Wichtig für PA-Konformität:
- Verträglich mit Contract #6 — die ToolsetSnapshot bleibt vollständig;
Deferral ist reine Präsentation an das Modell, kein Live-DB-Query.
- Muss durch die Governance-Gates — der Deferral-Katalog ist die bereits
gegatete Slice (Tier/Untrusted/Tag/Deny), niemals der volle Registry-Satz,
exakt wie Hermes seinen Katalog auf Session-Toolsets beschränkt.
- Im durable Pfad muss der AG-UI-Stream die Bridge entpacken (echter
Tool-Name in den Events), analog Hermes' Unwrap (Contract #3 bleibt gewahrt).
A4. Deklarative Platform-/Surface-Hints.
Hermes' PLATFORM_HINTS + config.yaml-append/replace ist ein leichter,
fail-safe Konfig-Surface. PA macht das heute über DB-Surfaces — funktional
gleichwertig, aber schwergewichtiger. Optional: ein kleines deklaratives
Default-Hint-Set pro Comms-Domain (Email/Signal/WhatsApp/Matrix/Zulip), das die
Surface-Overlays ergänzt, statt pro Domain im Code zu streuen (passt zur
„capability-declared, nicht hardcoded"-Regel der Integrationen).
SKIP / bewusst nicht¶
- SOUL.md-als-Datei: PA ist multi-tenant + DB-zentriert;
agent_soulals User-Setting ist das richtige Äquivalent — kein Dateimodell übernehmen. - Manuelle JSON-Tool-Schemas: PAs pydantic-abgeleitete Schemas sind besser; nicht zurückrüsten.
- dangerous-command-Approval (regex): PAs Security-Mode + Command-Policy + Untrusted-Gate sind bereits stärker.
- Auto-Discovery-Registry per AST: PAs expliziter
ToolsetAssemblerist für multi-tenant Governance klarer; nicht ersetzen.
5. Empfohlene Reihenfolge¶
- A1 Tier-Split + Prompt-Caching — World State aus dem stabilen Teil ziehen,
stable/volatile-Grenze einführen,cache_controlauf der kompatiblen Provider-Schiene. Größter Sofort-Nutzen, rein additiv. - A2 Modellfamilien-Guidance — kleiner Conditional an
auto_model. - A3 Tool Search — sobald MCP-Last real wird; durch die bestehenden Gates + AG-UI-Unwrap. Architektonisch der größte Brocken.
- A4 deklarative Domain-Hints — optional, niedrige Priorität.
Vorher offene Produktfrage für A1/A3: Caching nur auf Claude/Anthropic-Schiene zuerst, oder direkt provider-übergreifend? Und: Tool-Search-Default
auto(wie Hermes) vs.offbis MCP-Last messbar ist.
6. Umsetzungsstatus (alle vier umgesetzt)¶
Entscheidung: alle vier, Caching provider-übergreifend, Tool-Search-Default
auto (no-op unter Schwelle → sicher per Konstruktion). Unit-getestet; die
e2e-/requires_services-/requires_llm-Tests laufen nur mit lokaler DB/LLM.
| Item | Status | Kernänderung | Tests |
|---|---|---|---|
| A1 Prompt-Caching | ✅ | resolver.apply_prompt_cache_settings in with_provider_defaults (geteilte Seam → resolve + override + auto + durable RunSpec). Anthropic: anthropic_cache_instructions + anthropic_cache_tool_definitions; OpenAI/Google/DeepSeek prefix-cachen automatisch. |
test_model_settings.py |
| A2 Tool-Use-Guidance | ✅ | run_instructions.tool_use_enforcement(model_label) — Block für schwächere Caller (GPT/Gemini/Qwen/…), no-op für Claude; im stabilen Cache-Präfix von _layer_instructions (getrieben vom RESOLVED Label → folgt Auto). |
test_tool_use_enforcement.py |
| A3 Tool Search | ✅ (inline) | agent/tool_search.py über pydantic-ais DeferredLoadingToolset + ToolSearch-Capability; nur Integrations-/MCP-Toolsets, nur inline-Top-Level (assembler.tool_search_path), untrusted-ids auf Wrapper remapped (#13), Capability atomisch via factory.build(extra_capabilities=…). Echte Tool-Namen → AG-UI (#3) unberührt. |
test_tool_search.py |
| A4 Platform-Hints | ✅ | agent/platform_hints.py (Domain→Stil + append/replace-Override, fail-safe), in die Comms-Triage-Hülle (triage_context) eingehängt; no-op für eigene/unbekannte Domains. |
test_platform_hints.py, test_triage_context.py |
Follow-ups — ebenfalls umgesetzt¶
| Follow-up | Status | Kernänderung |
|---|---|---|
| A1 / OpenRouter-Caching | ✅ | build_byok baut den openrouter-Provider über pydantic-ais dediziertes OpenRouterModel (statt generisch OpenAIChatModel); apply_prompt_cache_settings setzt openrouter_cache_instructions + openrouter_cache_tool_definitions (an Anthropic/Gemini downstream weitergereicht, no-op sonst). |
| A4 / Config-Override | ✅ | Settings.platform_hints (JSON via PERSONAL_AGENT__PLATFORM_HINTS, Hermes-Parität); render_from_raw faltet die Overrides in resolve_platform_hint. |
| A3 / durable Worker | ✅ (opt-in) | Durable Chat-Agent defert die Integrations-/MCP-Toolsets + ToolSearch mit lokaler keywords-Strategie (native Suche ist nicht replay-stabil); hinter Settings.durable_tool_search (default off, da der Temporal-Pfad hier nicht integrationsgetestet werden kann → Operator schaltet nach Live-Validierung frei). |
| A3 / Sub-Agents | ✅ bewusst ausgelassen | Worker sind fokussiert + kurzlebig und laufen schon unter Tool-Constraints (NoDeferToolset); Deferral brächte nur Discovery-Round-Trips ohne Nutzen. |
Abschluss-Runde — alles fertig gebaut¶
| Item | Status | Kernänderung |
|---|---|---|
| A1 / CachePoint | ✅ | agent/prompt_cache.with_cache_point hängt einen CachePoint an den Inline-Prompt → Anthropic/Bedrock/OpenRouter cachen den Konversations-History-Präfix über Turns; OpenAI/Google/generisch filtern ihn weg (no-op). Round-trip-sicher über ModelMessagesTypeAdapter. |
| A1 / Static-Dynamic-Split | ✅ | _layer_instructions liefert (stable, volatile): stabiler Präfix als statische (gecachte) Instructions, volatiler Teil (World-State, Kompression, Title/Goal/Tag-Gate, Hook-Output) als dynamischer Funktions-Block → echtes Cross-Turn-Caching. Gerenderter Text byte-identisch (Test). Inline-Pfad. |
| A3 / durable auto | ✅ (opt-in) | AutoDeferToolset zählt pro Run die Tools des dynamischen Integrations-Toolsets (deterministisch → replay-safe) und defert nur über der Schwelle. Hinter durable_tool_search. |
Generischer OpenAI-kompatibler Anthropic-Proxy: kein sauberer Weg — pydantic-ais
OpenAI-Adapter filtert CachePoint und sendet kein cache_control. Nur der dedizierte
OpenRouterModel/AnthropicModel/BedrockModel tragen Caching. Bewusst offen gelassen.
7. pydantic-ai-Feature-Audit (1.107) — was wir noch nutzen könnten¶
Audit der installierten pydantic-ai-Primitive vs. PA-Nutzung. Gut genutzt:
Instrumentation, Hooks, ToolReturn, FallbackModel, UsageLimits, der Filtered/Wrapper-
Gate-Stack (#13), Tool Search (mit Determinismus-Split), CachePoint + Cache-Settings.
Empfohlen — alle drei jetzt umgesetzt:
1. Static-/Dynamic-Instructions (InstructionPart) — ✅ _layer_instructions liefert
(stable, volatile); stabiler Teil statisch (gecacht), volatiler Teil als Funktions-Block
(dynamisch, ungecacht). Cross-Turn-Caching, gerenderter Text byte-identisch.
2. args_validator= — ✅ control_entity (endlicher Setpoint, kein NaN/Inf) +
get_neighbors (Tiefe 1–3) werfen ModelRetry vor dem Tool-Body / DB-/Geräte-I/O.
3. Device-Identität im Schema — ✅ jede dev_<id>_*-Tool-Beschreibung trägt jetzt den
Geräte-Namen ([Bastis MacBook] …), damit das Modell weiß, auf welcher Maschine es
handelt. Idiomatisch zur Build-Zeit (das Toolset wird ohnehin pro Run gebaut) statt via
PreparedToolset — letzteres wäre hier redundante Indirektion.
Zusätzlich (Hermes-Parität): Das aktuelle Datum/Uhrzeit wird direkt in die (volatile)
Instructions injiziert (current_time_instruction) statt per Tool — get_current_time ist
aus dem interaktiven Chat raus (bleibt für Code-Execution-Skripte + Sub-Agents).
Bewusst übersprungen (Begründung): load_capability/Capabilities-on-demand (Kontext schon
via Tool-Search + Kompression gemanagt), native Tools/Embeddings (PAs eigene sind plattform-
getunt), strukturierter Output (Text-Streaming + AG-UI #3 funktioniert), PrefixedToolset/
SetMetadata/ExternalToolset (kosmetisch/Zukunft).