Friday · Build
Extending the engine
How to add to the Go engine without breaking what it promises. Read
Architecture first; the working method (track, change in compartments, recheck)
is docs/continuation/WORKFLOW.md.
This documentation is in English.
On this page
Ground rules
Everything below is built to the mechanism design system: its names,
words and measures are checked by go test -run '^TestConform' ./..., which bun run recheck runs
every time.
- Same meaning on both engines. A mode, an approval, a tool name and its arguments, an event's JSON: the TypeScript agent is the reference until a feature is ported. The interface must not be able to tell the engines apart. When the meaning must change, change both.
- Layering is enforced. Each
internal/core/<pkg>may import only whattooling/check/go-sections.jsonlists forcore/<pkg>. A new dependency between packages is a design change: update the matrix, Architecture andGO_ENGINE.mdtogether. - No file over 800 lines (the
sizeguard). Split by what the parts do. - Never quietly drop a limit. A flag, setting or rule the engine cannot honour is refused, or sends the session to the TypeScript engine; it is never ignored.
- Every user-visible word says what it does. Command descriptions, tool descriptions, error messages: a reader who has not read the docs should know what happens and what to do next.
Add a tool
- Write it in
internal/core/tools/as a method on*Workspacereturning*tool.Tool, in the file of its family (or a new<family>.go):
func (w *Workspace) countLines() *tool.Tool {
return &tool.Tool{
Name: "count_lines",
Description: "Count the lines of a file. Use it before reading a very large file in windows.",
Schema: tool.ObjectSchema(map[string]any{
"path": map[string]any{"type": "string"},
}, "path"),
Capability: tool.CapRead, // which modes have it (policy.CapabilitiesForMode)
Effect: tool.EffectNone, // none: never asks; workspace: asks; external: asks
ParallelSafe: true, // may run beside other effect-free calls in one step
Run: func(ctx context.Context, tc tool.Context, args tool.Args) tool.Result {
abs, rel, err := w.resolve(tool.StringArg(args, "path")) // refuses paths outside the project
if err != nil {
return tool.Error(err.Error())
}
// … do the work; respect ctx for anything slow …
return tool.Result{Content: rel + ": 42 lines", Data: map[string]any{"path": rel, "lines": 42}}
},
}
}- Add it to
Builtins()inworkspace.go. - Decide:
- Effect. If whether it changes something depends on its arguments, set
EffectFor(seerun_command): the ledger and the loop's batching use the per-call effect. - Errors are results. Return
tool.Error("what went wrong and what to do"); never panic (the loop recovers a panic into an error result, but the message is worse). - Evidence. If a successful run proves something works, set
Result.Evidence(tool.EvidenceCheck<Tests<Smoke<Behavior); if it can fail as a test run, setData["verificationKind"]so the gate can see red. - Deferred? If most turns never need it, add its name to
rarelyNeededinagents/session.go; it is then found withtool_search. - Sub-agents. Read-only roles get it only if its effect is none and it is parallel-safe;
role files can name it in
tools:. If no sub-agent should ever have it, add it toroles.NeverDelegated.
- Effect. If whether it changes something depends on its arguments, set
- Interface: for the card to show what the call is about ("count lines src/app.go"), add a case
for the tool to
DescribeToolCallininternal/render/toolcall.go. Editors get a title from the first ofpath,command,pattern,query,taskit has (server.toolTitle). - Document it in Tools.
Add a built-in role
Add a Profile to builtIn() in internal/core/roles/roles.go: name, a description the model
reads beside the name, instructions, ReadOnly, Model (ModelMain or ModelSmall), limits, and
Tools (an allow list) or Without (a deny list). Extend DelegationGuidance if the main
conversation needs to know when to use it. Document it in Sub-agents. A role
a project needs but Friday should not ship is a role file, not a built-in.
Add a provider
- Add a
Providertollm.Providersinproviders.goin the TypeScript agent's catalog order: id, label, required variables, default model,Kind, andBaseURLfor a compatible endpoint. Keep it identical topackages/core/src/providers/provider-specs.tsandagent-matrix.ts. - If it speaks Chat Completions,
KindCompatibleis all it needs. Otherwise add a case toOpenModelinmodels.go. Prefer a small native client (asanthropic.go) over a dependency that brings SDKs Friday does not use; check the binary size before and after (go build -trimpath -ldflags="-s -w"). - A native client must stream, yielding text, tool-call start, input deltas, the tool call, and a
finish with usage. It yields a keepalive for events that carry nothing; the idle watchdog depends
on it. It returns failures as
*fantasy.ProviderErrorwithStatusCode, lower-casedResponseHeaders, andContextTooLargeErrwhen the provider says the prompt is too long. - Tests:
llm_test.gofor selection, anhttptestserver replaying the provider's real stream for the client (seeanthropic_test.go). - Document it in Configuration.
Add an ACP method or session command
- A method: a case in
server.dispatch(methods.go) and a handler. A method that only reads or writes shared settings can stay forwarded (forwarded). Add the client call tointernal/acp/client.goif the interface needs it. Document it in Protocol. - A session command (
/namein the interface): a case inserver.command, replying with akindthe interface already renders (text,todos,diff,turns,review,error). Register the interface's side ininternal/tui/cmd_registry.go(one row: name, argument hint, a description that says what it does) with its handler in thecmd_file of its family. promptis special: it is accepted on the reading goroutine (acceptPrompt), so its order and cancel behaviour hold. Anything else that must keep its place in line goes throughSession.inTurn(as/compactdoes).
Add an event
Runtime events are loop.Event, whose JSON is the TypeScript runtime's AgentRuntimeEvent. A new
event type must be added to the TypeScript side's type too, or the interface must tolerate it
(internal/acp/types.go RuntimeEvent, internal/tui/core_events.go). Session-level events are
agents.Notice kinds, turned into _archymedes/event by server.extensionEvent. Document both in
Protocol.
Port a TypeScript feature
- Find the TypeScript module and its tests. Port the tests first: the cases are the specification.
- Keep names, texts the model reads, argument names and JSON shapes; change only what Go requires. Where JavaScript and Go differ (regular expressions, sorting, number formatting), make the Go behave like the JavaScript and test the difference against Node.
- Remove it from "not in the Go engine yet": the list in README,
GO_ENGINE.md, and, if it was a reason to fall back, the rule inchooseEngineand its test. - Record it in the tracker.
Make a session fall back to TypeScript
When a session needs something the Go engine cannot do yet, add a rule to chooseEngine in
cmd/friday/engine.go with a why a person can read, and a case to
TestTheGoEngineIsTheDefaultAndTheTypeScriptOneTakesWhatOnlyItCanDo. A flag the engine cannot
honour belongs in engineOptions, which refuses it (and so falls back). Document it in the
README table.
Tests to write
| Change | Test |
|---|---|
| A tool | its behaviour, refusals and limits in tools_test.go; its effect per call |
| Policy | table cases ported from TypeScript, plus Node-checked cases for any rewrite |
| The loop | a scripted model (loop_test.go): the events, the result, what the next request contained |
| The coordinator | the fake model harness (agents/fake_test.go); -race, and -count=10 for anything timing-related |
| The protocol | cmd/friday/engine_test.go, through acp.Client, as the interface uses it |
| What a person sees | packages/friday-cli/src/pty/go-engine.test.ts, the real binary against a stub |
| A provider | an httptest server replaying the real stream; and once, live, with FRIDAY_LIVE=1 |
A test that depends on goroutine timing must wait for the condition it checks (waitUntil), never
for a status that is set a moment before it.
Checklist
- Task added to the tracker before the code
go test -race ./internal/core/... ./cmd/fridayandbun run recheckpass- Layering matrix and docs updated if a package dependency changed
- Both engines mean the same thing, or the difference is documented
- User-visible words say what happens and what to do
- Docs in
docs/engine/updated; evidence recorded in the tracker
Kept in the repository as docs/engine/EXTENDING.md, checked by its conformance tests · synced at af2a9b2