Agents
An agent is an LLM that works on one book on its own, for a long time, and calls commands as its tools; one
execution of it is a run. This page is how it all works inside: the pieces, the life of a run, what the model sees and can call,
how the user is brought in, and how to add to it. What the user sees is in the user guide; the entities
(Agent, AgentRun, AgentMessage) are in the domain model.
The central idea: an agent is not a new kind of program but a loop around the things that already exist. The model is asked
through the same InferenceService as everything else, the tools are the same commands the command bar runs, and a run works on an
ordinary (hidden) copy of a book in the same services. What an agent adds is a way to run all that without a browser: a request
context made from the database, a state machine that can be paused and resumed, a workspace of files, and a transcript that is saved after every round.
flowchart LR
User["User<br/>(Agents tab, console)"] -->|start, pause, stop, message| RS["AgentRunService<br/>state of the run"]
RS -->|run| Runner["AgentRunnerImpl<br/>one worker thread per run"]
Runner -->|round: prompt + transcript + tools| Inf["InferenceService"]
Inf -->|text and tool calls| Runner
Runner -->|calls| Tools["FunctionSet"]
Tools --> Cmd["CommandFunctions<br/>the allowed commands"]
Tools --> Files["AgentFileTools<br/>workspace files, results"]
Tools --> Run["RunTools<br/>finish, ask_user, plan, status"]
Cmd --> CS["CommandService<br/>the same commands as the command bar"]
CS --> Copy["Working copy of the book<br/>and its lorebooks"]
Runner -->|append| Msg["AgentMessage<br/>transcript"]
Runner -->|transition| RS
RS -->|notice| Att["AgentAttentionService"] --> UserThe pieces
The permission is PermissionType.AGENTS; the limits of it (concurrent runs, token budget, number of agents) are read by
AgentService.getGrant.
The life of a run
A run is a row (AgentRun) with a status (AgentRunStatus, stored by name). Everything else about it, the transcript, the
counters, the working copy, hangs on that status, and only AgentRunService changes it, with a conditional SQL update (the caller says
which status it believes the run has; an out of date caller gets an IllegalStateException and looks again).
stateDiagram-v2
[*] --> RUNNING: start
RUNNING --> IDLE: the model answers in text, or ask_user
IDLE --> RUNNING: the user writes
RUNNING --> WAITING_APPROVAL: a command of an ASK agent
WAITING_APPROVAL --> RUNNING: decided
RUNNING --> PAUSING: pause, a limit
PAUSING --> PAUSED
IDLE --> PAUSED
WAITING_APPROVAL --> PAUSED
PAUSED --> RUNNING: resume
RUNNING --> STOPPING: stop
STOPPING --> STOPPED_KEPT
STOPPING --> STOPPED_DISCARDED
PAUSED --> STOPPED_KEPT
STOPPED_KEPT --> RUNNING: resume
RUNNING --> DONE: finish
IDLE --> DONE: Finish in the console
RUNNING --> FAILED: the runner broke
DONE --> APPLIED: the review, AgentMergeService.apply
STOPPED_KEPT --> APPLIED: the review(The complete table of the allowed changes is AgentRunStatus.next(); the diagram leaves out the discards.)
Three questions are asked of a status, each a method of the enum:
-
isActive(): a runner is (supposed to be) busy:RUNNING,WAITING_APPROVAL,PAUSING,STOPPING. After a restart of the server a run found in one of these was interrupted (recoverInterrupted). -
holdsLock(): the book of the agent is locked, seeThe working copy and the lock .IDLEholds it too: the user is in the middle of a conversation. -
needsAttention(reason): the user should hear of it, seeBringing the user in .
Pause and stop are requests, not interruptions. The user's button moves a RUNNING run to PAUSING / STOPPING; the worker looks at the
status at the top of every round (the stable point) and ends the change there. A model request or a command that is going on finishes first. A
run that waits for the user (IDLE, WAITING_APPROVAL) is paused at once, and when no worker is registered for a run the service finishes the
change itself, so nothing ever waits for a thread that does not exist. A run that is thrown away (deleted, discarded) is the exception:
the worker polls for that while it waits and cancels the request.
A run survives a restart. Every round is appended to agent_messages before the next request, the status is in the database, and the working
copy and the workspace are on disk. A resumed run reads its transcript back, repairs its end (calls nobody answered get "interrupted", a transcript
that ends with a plain reply gets the resume message, see AgentRunnerImpl.Worker.prepare) and goes on. The plan (plan.json, written by
RunToolsImpl.onPlanChange after every accepted update_plan; without the file, as for a run that was rewound, it is read from the last update_plan call that the
model still reads, planOf) and the CommandResults (results.json, the last ten whole and the number the next gets; CommandResults.onChange / restore) are
files of the base folder too. Only the main agent's results are kept, those of a sub-agent live as long as it does.
Restart in the middle of a round. The assistant message with its calls is stored before the calls are made and the results one by one, and the calls of a round
are made in order, so after a death only the first call without a result can have been going on; the others were never started. Some small files in the folder of
the base (AgentVirtualFileSystemService, written at once, no owner check, a failure is only logged) tell the rest: call-began (writeCallMarker) holds the id of
the call that is about to change something (Worker.call, only for changes(call); approveCall clears it while the call waits for the author's approval and sets it
again when they agree), queue.json (writeQueue) is the Inbox, resume-note.txt the note of the resume, plan.json and results.json the plan and the results of the commands (see above), delivery.json what the
worker took from the queue and has not finished, and call-began-<handle> the same as call-began for a sub-agent. Worker.repairCalls(transcript, true) (from prepare)
does not answer the open calls as interrupted but keeps them in replay; the loop makes them as a round of its own (no request, replayed), skipping the model, and a
call for which unsafeToRepeat is true (its id is the marker and it changes something, or it is a sub-agent tool) is answered as interrupted and the rest as skipped after an
error. The report of the sync and the note of the resume (resumeMessage) come after the results (resumeMessagesDue): a message of the author between the calls and
their results is not allowed. Approvals are not stored: the call waiting for one has no result, so a restart finds it open and unmarked, and making it again asks again.
A pause or stop while a call waits must not look like a denial: AgentApprovalService.rejectAll notes that approvals were withdrawn (takeWithdrawn, cleared by the next
request), approveCall turns that into withdrawnCall, and the loop leaves that call without a result (the suspended path, as for a call that waits for a sub-agent).
The Inbox calls a store function after every change (storeQueue, under its lock, the images by the path of their file); inboxOf / existingInbox read the file when the
run has no inbox in memory (the queue of a paused run is shown and cancelled after a restart), forgetInbox removes it with the run. Worker.deliver takes the items out of the queue, so it keeps the ones it has not
finished in delivery.json (storeDelivery; began says that the first, a command, is running) and removes each when it is done. A restart finds it in work()
(takeDelivery): the items go back to the head of the Inbox (to initial for a worker that only runs commands), and a command that had begun is not run again unless
it is /compact (nothing changes until its summary is stored) or /clear-workspace (deletes the same files again): the others may be done in part, so the agent is
told Defaults.AGENT_AUTHOR_COMMAND_INTERRUPTED and the command line. AgentRunner.parkAll(maxMillis) is what destroy() calls: it adds the working runs to
interrupts and sets shuttingDown, the workers give up the request or call that goes on and pause the run at the stable point (parkForShutdown), and it waits up
to the time given (PARK_MILLIS, 5 s); whatever is left is cut off and paused by recoverInterrupted at the next start. The open calls of a sub-agent are treated the same way
when it continues (Worker.replayCutOff, before its first request): callSub writes call-began-<handle> for a call that changes something and the loop clears it with the
result, so the calls that did not begin are made, the one that had begun is answered as interrupted and the ones after it as not made. A kill -9 cannot be handled: a
command that was running dies with the JVM, and what it did is told as above.
The request context of a run
The commands, the services and Localization find the user and the open book through session scoped beans (user, sessionPoint),
which resolve against the request attributes of the thread. A run has no browser, so AgentRunService.openSession(run) builds the context from
the database at every start and resume:
-
AgentSession(userUuid, language)is all a run stores of its session: the owner, and the language of the agent (Agent.language, copied toAgentRun.languagewhen the run starts: neither the interface language of the user nor the language of the book; the agent sees one fixed language, English where its language has no bundle). -
AgentRequestAttributesimplementsRequestAttributesover two maps in memory (session and request scope). It starts with theuserbean (a copy of the owner, as the login makes it) and anAgenticSessionPoint. Any other session bean is created on first use, empty. -
AgenticSessionPointis aSessionPointthat holds the working copy directly.CommandUtils.getSessionPoint()returns it for these attributes (and the bean of the servlet session for a browser). A command no longer reads the book from there: it takes it fromcontext.getScope(), and the runner runs the commands of an agent in the scope of its run (CommandScopes.forRun(run), see The scope), so the same command works at the command bar and for an agent and never knows who is asking. This is why a command must never keep the book in a field or reach forWorkspace. -
The worker enters it with
InRequestScope; every thread that works for the run does the same.ThreadCopyRequestAttributes.create()hands theAgentRequestAttributeson as it is (a browser session is copied, these are shared), so a command that starts a worker thread (async = true, or the generation of a story turn) keeps the context. A test wrapper that is not anAgentRequestAttributesis not handed on: in tests of anasynccommand use the real class.
The context checks nothing and is for the runner; whoever builds one (tests, AgenticRequestAttributes in the test sources) must pass a user that
owns the book.
The working copy and the lock
A run never touches the book of the user. AgentRunService.start makes a hidden working copy (ManuscriptService.createAgenticCopy: the
book through its backup JSON, with its own copy of the lorebook tree; everything has agentic = true, new uuids, nothing shared) and stores
it as AgentRun.workingCopy. The lists of the UI leave the agentic books out (findAllRegular...), the services that delete a copy refuse a book that is not
agentic, so a path that deletes the book of a user does not exist. FAILED, a discarded stop and APPLIED delete the copy for good (COPY_ENDING); a pause, a kept stop and DONE keep it,
DONE until the result is applied or discarded (see
The copy is put into the folder of the original (ManuscriptService.setAgenticCopyFolder, called by createAgenticCopy, by
AgentRunServiceImpl.replaceWorkingCopy when a checkpoint is restored and by AgentMergeServiceImpl.sync when the book was moved while the run
was paused), so it resolves the language, templates and prompts through the same folders as the book. It is
not content of the folder: FolderModification, the counts and the folder lock skip agentic books, and a merge (a JSON diff without a
folder) never moves the original. The reverse holds: a book with a holding run blocks the move or delete of a folder above it and any change
of what it inherits (FolderBookLockedException).
The copy has new uuids, so the run records how it relates to the original, in the transaction that makes the copy:
-
The uuid pairs (
AgentRunPairService, tableagent_run_pairs, V17): oneAgentRunPairrow per message, summary, lorebook, lorebook entry and annotation (AgentRunPairKind.ANNOTATION, the letteraof the handle,h:a3) of the copy,kind+copyUuid->originalUuid(null for an item the agent made; it has none until it is applied) + ahandlecolumn that is empty until the agent is given handles.ManuscriptService.createAgenticCopyreturns anAgenticCopy(the copy, theUuidPairs, the base);cloneFromBackup(backup, pairs)andLorebookService.copyLorebooks(lorebooks, pairs)report the pairs of the messages, summaries, lorebooks and entries as they make them, andAgentRunServiceImpl.startandresume(for a run without a copy) store them withAgentRunPairService.replaceonce the run is saved.AgentRunPairis a plain entity (noBaseEntity: no uuid, no soft delete, no extended content, a book of 2000 parts is thousands of rows) keyed by the id of the run, so the cleanup does not know it:AgentRunServiceImpl.releaseCopydeletes the rows (deleteForRun) when the run lets go of the copy, whichever way that happens. The reading methods work for the runs of the user only. -
The base snapshot: the backup JSON of the original as it was copied, with the lorebook tree and the uuids of the entries (the backup format has none, so
LorebookService.marshalLorebooks(root, true)adds them; the copy of the lorebooks is made from this very JSON, so base and pairs agree), written toagent-runs/<run uuid>/base/manuscript.jsonbyAgentVirtualFileSystemService.writeBase(deleted again when the transaction rolls back) and read withreadBase. It is deleted with the copy (deleteBase) and with the run folder.
The model of the copy. The copy is made from the backup, so it has the AI and the protocol of the book (or none). Agent.useAgentModel (an extended attribute, isUsingAgentModel(): null is on) says what the run writes with. AgentRunnerImpl.Worker.work calls AgentRunService.useAgentModel(run) every time a worker starts: on, it writes the AI and the protocol of the main profile of the agent into the copy (ManuscriptService.setAgenticCopyModel, an exact set, refuses a book that is not agentic); off, it writes the ones the original book has now back (the flag may have been on in an earlier session). So the generation commands without a profile (options of StoryGenerationCommands, the summary commands) use the agent's model, and a profile or a flag the author edited between a pause and a resume is picked up. The two are not in MergeDiff.BOOK_FIELDS, so neither the review nor apply nor the sync on resume ever touches the AI and the protocol of the original; a restore of a checkpoint keeps what the snapshot had, the next worker start sets them again.
The commands of extensions that call a model do not read the book, so they ask the scope: CommandScope.getModel() (a ModelChoice(ai, protocol)) is, for the scope of a run, the main profile of the agent when the flag is on, else the model of the copy (null when it has none); for the scope of the session it is null, and the command uses its own choice (the Lore Generator the default model of the user, the Arc Outline the model of the book first). The Lore Generator and the Arc Outline commands (requireModel(context...)) take it first. An extension that starts a model on behalf of a command should do the same, or an agent that was told to mix models will not.
While a run holdsLock() the original book is locked: AgentRunService.checkNotLocked(manuscript) throws ManuscriptLockedException, and the services
that change a book call it (ManuscriptService.save/delete/restoreBackup, ChatMessageService, SummaryService, ManuscriptReplaceService.apply,
StoryGenerationService.generateNextTurn). Reading is free. The screens that open a book ask AgentLockCheck first
so the user is told which agent holds it instead of getting an error later. A change that a service does not route through these methods is not locked.
The lorebooks are locked with the book, because the run works on a copy of them and the copy is compared with the lorebooks as they were: the lorebook the book links and its subbooks, however deep (AgentRunService.findLockingRun(Lorebook) / checkNotLocked(Lorebook), which throw LorebookLockedException, a ManuscriptLockedException; the working copies of agents are never locked). LorebookService and LorebookEntryService check it in save and delete. A lorebook has no screen of its own to tell the user, so LorebookView shows a locked lorebook with the PhoneLorebookView (nothing else of the screen), which says which agent holds it and changes nothing; both views read the lock again on Refresh. The tags of a lorebook are not locked.
The services
AgentService and AgentRunService (domain.service) manage the definitions of agents and their runs, see
Agent and AgentRun. AgentRunner works a started run, see
AgentService extends ExtendableService. save checks that a new agent is allowed (getGrant(user).allowed(): administrators, who need no granted permission but get the limits of their own AGENTS permission and the defaults when it is empty or missing (grantOf),
or a user with the granted permission AGENTS; the grant is read when a run's worker starts, AgentVirtualFileSystemService.open), the manuscript exists and is the user's own, a manuscript has no other agent and the
manuscript of an existing agent is not changed, the instructions are at most MAX_INSTRUCTIONS_LENGTH, there are at most
MAX_MODEL_PROFILES profiles with unique non-blank roles and positive limits; names, roles and the allowed commands are trimmed (commands
also de-duplicated). It does not check that the AI and protocol of a profile exist: resolveProfile(profile) does, when the agent is used.
Agent.manuscript is lazy (a book carries the data of all plugins, about 3 MB with its prompts): outside a transaction read the book with findManuscript(agent) (the user's own agent only, else null) and the names of many books for a list with findBookNames(agents) (by the uuid of the agent, one query, no book loaded); the Agents tab and AgentDialog use them. An existing agent can be edited after the permission was taken away, it can't run. getGrant(user) is an AgentGrant(allowed, maxConcurrentRuns, runTokenBudget, maxAgents, maxCopies, maxFileBytes, maxWorkspaceBytes, maxWorkspaceFiles, maxCheckpointBytes, maxConcurrency): everything for an administrator (the
quotas are the defaults), else the settings of the AGENTS permission (1 run, no budget, DEFAULT_MAX_AGENTS = 5 agents when not set; the quotas are
Permission.agentsMaxCopies, agentsMaxFileKb, agentsMaxWorkspaceMb, agentsMaxWorkspaceFiles, where none, zero or below is the default: no limit of copies,
and the WorkspaceLimits of the application for the files; the four argument constructor of the record keeps the old callers). AgentRunService.start (and
resume of a run that lost its copy) refuses with an IllegalStateException when countWithWorkingCopy of the user has reached maxCopies: every run that
is not finished keeps its copy, paused and stopped-and-kept ones too. save of a new agent (so cloneAgent too) refuses with an IllegalStateException when the user already
has maxAgents agents (the deleted ones don't count); canCreate() is the same rule for the UI. cloneAgent(source, name, manuscript) copies the definition to another manuscript of the user, findForManuscript,
deleteForManuscript(manuscript, hard) (called by ManuscriptService.delete) and delete, which deletes the runs first.
AgentRunService extends ExtendableService. The status and the counters change only here, by SQL updates (AgentRunRepository):
-
start(agent)creates a run inRUNNING(with the language of the agent,AgentRun.language, a language code). It refuses (SecurityException,IllegalArgumentExceptionfor an agent that is not the user's,IllegalStateExceptionotherwise) when the user is not allowed, the agent is disabled, has no main model or the main model can't be resolved, the agent has an unfinished run, or the user has all the concurrent runs of the grant (RUNNING,WAITING_APPROVAL,PAUSING,STOPPING; a run waiting for the user inIDLEis not counted). -
transition(run, to, reason)changes the status alongAgentRunStatus.next(). The status of therunargument is the one the caller believes the run has: the update is conditional on it (and on the owner), so a runner whose run was paused by the user in between gets anIllegalStateExceptionand has to look again. The update comes first and nothing is read before it: in WAL mode SQLite fails a transaction that reads and then writes when another transaction wrote in between, at once and without waiting. It returns the run as it is now. -
addUsage(run, prefill, generated)adds to the counters and counts a round, safe for the parallel threads of a run;getTokenBudget(run)is the smaller of the agent's budget and the grant's (0 is none),isBudgetExceeded(run). -
startmakes the working copy of the run before it saves it (ManuscriptService.createAgenticCopy: the book through its backup JSON without the lorebook link, plus its own copy of the lorebook tree fromLorebookService.copyLorebooks; every copy hasagentic = true, new uuids and nothing shared with the original) and stores it asAgentRun.workingCopy.transitiontoDONE,FAILED,STOPPED_DISCARDEDorAPPLIED(theCOPY_ENDINGstatuses) deletes the copy for good and sets the column to null (AgentRunRepository.updateWorkingCopy, an SQL update first, the run lets go of it before the copy is deleted:ManuscriptService.deleteAgenticCopyhard deletes the messages with their summaries (ChatMessageService.hardDeleteForManuscript, the soft deleted ones too, children first), the tags and the copy's lorebooks (LorebookService.deleteAgenticCopy: the tree, its entries and tags); both refuse anything whoseagenticis false, so a book of the user can't be deleted by this path).PAUSEDandSTOPPED_KEPTkeep the copy,resumemakes one for a run that has none, a new run makes a new copy. The pairs and the base snapshot of the copy are stored with it (above). Not done: the merge back. -
delete(run, hard)discards a run that is not over (STOPPED_DISCARDED) and deletes its working copy for good first. -
pause(run),stop(run, keep)andresume(run)are what the buttons of the Agents tab call. A run that waits for the user (IDLE,WAITING_APPROVAL) is paused at once; aRUNNINGone goes toPAUSING/STOPPINGand the runner finishes the change at its next stable point, unless no worker is registered for it (workerStarted(run)/workerFinished(run), the runner's to call, a set of run ids in memory), then the service takes the second step itself, so nothing waits for a runner that does not exist.stopof a run that is paused, kept or done changes it at once; discarding deletes the working copy.resume(run, note)also keeps an optional note of the author for the model (AgentVirtualFileSystemService.writeResumeNote, a fileresume-note.txtin the folder of the base, written after the commit like the sync report; a second note before the first was read follows it): the runner reads it inWorker.prepare(takeResumeNote), once, and sends it in the same message as the sync report (the report,Defaults.AGENT_RESUME_NOTE+ the note,DEFAULT_AGENT_RESUME_MESSAGE); a run without a transcript gets it after the start message. It is a file and not theInboxso that a run that is not a conversation (no inbox) and a restart in between lose nothing. The conflicts of the sync are not stored: the base moves only for what was taken over, so the nextsyncfinds them again and the report names them again on every resume.resumechecks whatstartchecks (except the one-unfinished-run rule, the run is that run). All three take the status of therunthe caller has and fail with anIllegalStateExceptionwhen it is out of date. -
Attention.
AgentRunStatus.needsAttention(reason)says which statuses the user should hear of (IDLE,WAITING_APPROVAL,DONE,FAILED, aPAUSEDwith a reason); every status change writes it into the columnagent_runs.attentionin the same SQL update as the status (JpaAgentRunRepository.statusUpdate), so it is stored while the user is away.findNeedingAttention()reads it,acknowledge(run)clears it. After the transaction commits,start, everytransitionandacknowledgehand the run toAgentAttentionService.publish(every change, withNotice.attentiontelling whether it needs the user: a stop or a pause is finished by the worker later, and the tables of agents on the open screens have to show where it ended), which calls the listeners subscribed for the owner (subscribe(userId, listener), a plain observer in memory with no UI in it): the Agents tab of every open UI subscribes increate(), keeps theRequestAttributesof its session (ThreadCopyRequestAttributes.create()) and runs the notice throughui.access+InRequestScope. -
Approvals.
AgentApprovalServicekeeps, in memory, the commands an agent in theASKmode waits to run (requestreturns the future of the decision). The runner'sWorker.approveis theCommandFunctions.Approval: it registers the request, moves the runRUNNINGtoWAITING_APPROVAL, and returns a future that completes on a thread of the run in itsInRequestScope, so the command that follows is never run in the session of the user's browser;backToWorkmoves the run back toRUNNINGwhen nothing waits.transitiontoPAUSED,STOPPING,STOPPED_*,DONEorFAILEDcallsrejectAll.pending(run)anddecide(run, id, approved)check that the run is the user's. -
findLockingRun(manuscript)is the run of the non-deleted agent of the book whose statusholdsLock()(RUNNING,IDLE,WAITING_APPROVAL,PAUSING,STOPPING; whoever owns it),checkNotLocked(manuscript)throws theManuscriptLockedException(it carries the agent name) when there is one. It is called byManuscriptService(save,delete,restoreBackup),ChatMessageService(save,delete),SummaryService(creating, updating, attaching and removing a summary),ManuscriptReplaceService.applyandStoryGenerationService.generateNextTurn. Reading is free, lorebooks are not locked, and the lock follows the status: paused, stopped and finished runs release it. A change of the manuscript that a service does not route through these methods is not locked. -
openSession(run)makes the request context of a run (AgentRequestAttributes, packagebound) from the database for every start and resume. A run stores only itsAgentSession(userUuid, language)(AgentSession.of(run): the owner of the run and its language, the oneAgent.languagehad when it started (a code, English when not set; the combo box ofAgentDialoglistsLocalization.getAvailableLanguages(), and keeps a stored code whose bundle is gone, marked, until the author changes it); the UI language of the user and the language of the book do not matter, the agent sees one fixed language, English where its language has no localization bundle; the system prompt (AGENT_RUNNER_NOTE) tells it to write the book in the language of the book, whichget-book-inforeturns). The attributes are not a servlet request: they hold the session scope in memory, with theuserbean (a copy of the owner, with uuid) and thesessionPoint(anAgenticSessionPointon the working copy; a run without one fails with anIllegalStateException, the agent never gets the book of the user), socurrentUser, the services andLocalizationresolve on any thread that enters them withInRequestScope. All the threads of the run share the one object:ThreadCopyRequestAttributes.create()hands it on, so the commands and the generation worker started from a command keep the context. The runner closes it (close()runs the destruction callbacks) when the run stops working. It checks nothing, it is for the runner. -
recoverInterrupted()is for the start of the server, when no runner is alive:STOPPINGbecomesSTOPPED_KEPT, the other active statusesPAUSED, with a reason. It needs no user;AgentRunnerImplcalls it when the singletons are created (SmartInitializingSingleton). -
getStopTarget(run)andendStop(run):stopof a working run remembers in memory whether the result is kept and the worker ends the stop (STOPPINGto that status, the copy dropped for a discard); without a workerstopends it at once.workerFinishedforgets the choice.
The runner
AgentRunner (AgentRunnerImpl, domain.service) is what makes a run do anything. run(run) takes a RUNNING run up (it throws for another status, and
does nothing when a worker has it already): it registers the worker with workerStarted before the thread starts, so a pause asked at once waits for it.
send(run, text) is the message of the user to an IDLE run (IDLE to RUNNING, the message appended, a worker started); to a conversation
(run.isInteractive()) that is RUNNING, WAITING_APPROVAL or PAUSING it queues the message in the run's Inbox (in memory, by run id, like the approvals;
queued(run) lists them as QueuedMessage(id, text)). The worker reads the messages of the queue at the top of every iteration of the loop (deliverQueuedMessages: the leading messages up to the first command, Inbox.takeMessages, as one USER message, so the next request has them), and everything, commands included, where it would end the turn (endTurn: no tool call, finish, ask_user): under the
lock of the Inbox it either appends all queued messages as one USER message and goes on, or moves the run to IDLE; enqueue takes the same lock and re-reads the status, so a
message is never left queued on an IDLE run (it is sent as usual there). A resumed run delivers them instead of the resume message (prepare). cancelQueued(run, id) removes an item from the Inbox (Inbox.remove; false when it was delivered meanwhile; a wish to force that item goes with it, and deliverForced then delivers nothing). force(run, id) sets the
wish in the Inbox (or sends at once if the run is IDLE): await sees it every POLL_MILLIS and throws Forced, which cancels the request or the call; the calls of the round that
were written to the transcript get AGENT_FORCED_CALL (the one in flight) / AGENT_SKIPPED_CALL results, rejectAll refuses a waiting approval (the worker moves the run back to
RUNNING itself first: two writers of SQLite at once fail), and the messages up to the forced one are appended (deliverForced). An answer of the model that was not complete is not
written and not counted. command(run, line) is the same queue for commands of the author: consoleCommands(agent) names what may be run (compact, clear-workspace and the agent's allowed commands that are not
destructive: a destructive command asks the browser for a confirmation, which a run does not have), an item of the Inbox is a Queued(id, message, command) and QueuedMessage says command. Where the run is RUNNING
(a conversation), WAITING_APPROVAL or PAUSING the command is queued; where it is IDLE or PAUSED a worker with initial items does only that (start(run, initial), the run stays as it was,
send refuses while it is registered because the worker writes the transcript; after the commands it goes on into the loop only if the run became RUNNING meanwhile). deliver(due, transcript) runs the items in order
outside of the lock of the Inbox (a command can take long): messages go in as one message, a command is runCommand - /compact is compact, /clear-workspace is VirtualFileSystem.clearWorkspace(keep) (paths and globs, all or nothing, answer Deleted N file(s) and M folder(s); the console asks ConfirmDialog first), anything else CommandService.executeLine in the context of the run -
and its answer (or Error:, cut like a result of a call) is a USER message that starts with Defaults.AGENT_AUTHOR_COMMAND_NOTE, not a tool call of the agent. endTurn goes IDLE after commands alone, it
continues the loop when a message of the author was among them; a forced item (deliverForced) always continues. ignoreForce keeps a forced message from cutting off the command that is run for it.
payload merges messages of the author that follow each other (coalesced) because some chat templates refuse two of a role in a row, the transcript itself is not changed.
A stopped or finished run drops its inbox (queued and the worker's end). The UI calls run after
start and resume (run(run, firstMessage) appends that first message to the empty transcript of a run first, for the run started from the console, where the
agent is told to ask the author at once). A worker (a daemon thread of a cached pool) does this, in InRequestScope of the AgentRequestAttributes of openSession, which it closes at the end:
-
Loads the run, resolves the main profile (a model that is gone pauses the run), reads the transcript (
AgentMessageService.transcript) and repairs its end: a new run getsDefaults.DEFAULT_AGENT_START_MESSAGE, calls of the last assistant message without a result getDefaults.AGENT_INTERRUPTED_CALL, a transcript that ends with a plain assistant reply getsDefaults.DEFAULT_AGENT_RESUME_MESSAGE. The plan and theCommandResultsare read from their files of the run. -
Builds the tools:
CommandFunctions.forAgent(...)(with a reader of the files of the run and theCommandResultsof the run, seeReferences )andthe file tools ofAgentVirtualFileSystemServiceandRunToolsbound to aRunToolsImpl(a name used twice fails the run). TheASKapproval isWorker.approve, seeBringing the user in . -
Loops. At the top, the stable point: the status is read, and
PAUSINGbecomesPAUSED,STOPPINGends withendStop, any other status thanRUNNINGends the worker. Then the limits: rounds (Agent.maxRounds),isBudgetExceeded, and the context (the lowest cap of the agent, the profile and the AI against the tokens of the last request, the first one estimated); a limit pauses the run with the reason. The request isInferenceService.roundwith the system message (prompt, instructions,Defaults.AGENT_RUNNER_NOTE, for a conversation alsoDefaults.AGENT_INTERACTIVE_NOTE, and the limits of the model: the context cap andTokenLimits.responseTokens, aslimitsNote) and the transcript; a failure of it (also the limits of the user's AI) pauses the run. The usage goes toaddUsage, the answer is appended with its context and its reasoning (appendAnswer; the round callback'sonRound(text, reasoning, calls, usage)brings the reasoning, read fromreasoning_content/reasoningof the message), a reply without calls ends the turn (IDLE), otherwise every call runs one after another on a thread in the context of the run and the results are appended together (Error: <message>for a failure, as the inference service writes it). After the roundfinishmakes the runDONE(the report is the status reason, cut at 2 000 characters) andask_usermakes itIDLE(the question is the reason). -
While it waits for a request or a call the worker looks every second whether the run was thrown away (deleted or
STOPPED_DISCARDED) and cancels the request and ends; a pause and a stop wait.
Calls cut off in the middle. When the answer of the model ends inside a call (the limit of one answer, see below), the call has arguments that are not valid JSON; the function layer answers it with an error, and the row keeps what the model
wrote. The history is sent back at every request, and a provider that parses the arguments of the calls in it (LiteLLM in front of llama.cpp) refuses the whole request for the broken one, so from then on every request failed and
only a rewind helped. Worker.coalesced (it makes the payload of the agent and of a sub-agent) now sends such a call with {} as its arguments (withReadableCalls; the stored row is not changed), and the error result says that the answer was cut off.
Unreadable calls. A provider that parses the arguments of a call itself (LiteLLM and OpenAI-compatible proxies) refuses the whole answer when the JSON is cut off (the limit of one answer, TokenLimits.responseTokens, sent as max_completion_tokens only when the protocol or the AI sets one, or a default of the server, or the model stopping; not the context), so the request fails and the transcript did not change: a resume
repeats it and fails the same way (the run was "bricked" until the user rewound). AgentRunnerImpl.isUnreadableCall recognises the message (Failed to parse tool call arguments, arguments as JSON...,
in the exception or its causes); the loop (and subLoop) then appends Defaults.AGENT_UNREADABLE_CALL as a user message and asks again, at most MAX_UNREADABLE_CALLS = 3 times in a row (the counter is reset by an answer),
and pauses with the notices in the transcript when it goes on failing.
Cancelling a call. Worker.call (and callSub) hand await a cancel that sets the canceled flag the function reads (FunctionContinuation.isCancelled) and cancels the CompletableFuture that FunctionSet.call returned. FunctionSet runs the hooks a function registered with FunctionContinuation.onCancel(Runnable) when that future is cancelled (each once, at once when it was cancelled before the hook was added; the default method of the interface ignores them, so other implementations need nothing). CommandFunctions.run registers commandRun.cancel(true): the command service then completes the run, interrupts the worker thread and CommandContext.isTerminated() becomes true; a command that declared itself uninterruptable refuses the cancel. A call cancelled before its approval came starts nothing. await cancels on a pause at once (interrupts), a forced message, a run thrown away and, since the stop was changed from "waits for the answer", on STOPPING: Interrupted goes to the stable point which ends the stop, the call in the air is answered Defaults.AGENT_STOPPED_CALL and the rest of the round AGENT_STOPPED_SKIPPED_CALL (Worker.stopping()). An ordinary pause still waits for the call. The sub-agents' awaitSub cancels the same way.
Pause now. AgentRunner.pauseNow(run) is a pause that does not wait for the round: it puts the run id in the in-memory interrupts set first, then asks AgentRunService.pause. Worker.await looks at the set before every wait (every second, like the discard check) and cancels what it waits for with Interrupted, a subclass of Discarded so that the places that rethrow a discard rethrow it too. In the loop ask catching it drops the answer (nothing is written or counted) and goes to the stable point, which pauses; in a round whose calls are written the call in the air is answered Defaults.AGENT_PAUSED_CALL, the rest AGENT_PAUSED_SKIPPED_CALL, and the results are appended with the others of the round; anywhere else the exception reaches Worker.run, which settles the requested pause. The set is cleared when the worker ends. An ordinary pause or stop ends the wait for a place at the AI too (await(..., queued = true)), since nothing is lost by it.
A status change that finds another status than it expected (the user paused meanwhile) is not an error: reach and pause then do the pause or stop that was asked.
Anything the worker throws that is not one of these makes the run FAILED with the message. RunTools (domain.service.agent) are finish, ask_user, update_plan
(statuses pending, in_progress, done; the usual other spellings are mapped) request_compaction (the focus is noted, isCompactionRequested) and get_run_status; RunToolsImpl only notes the request of the model, the worker acts on
it after the round, so every call of the round has its result in the transcript first.
The transcript is AgentMessage (table agent_messages): the run, the ordinal, the thread (null: the agent's own conversation, otherwise the handle of a sub-agent, V18) and the LLMChatMessage
as JSON in an extended attribute, only appended; the creation of the row is the time the console and the export show. The system message is not stored.
InferenceService.round(payload, protocol, functions, callback) is what the runner uses instead of stream/infer: one request that is streamed to the provider and put together by the service (so the timeout is the wait for the next chunk, the reasoning included, not for the whole answer), and that reports the
text, the calls the model asked for (they are not called) and the tokens (the provider's, otherwise ours), with the limits and the usage of the user as for any
request. Services that can't do it throw UnsupportedOperationException.
Sub-agents
A sub-agent is a thread of the run, not a run: same working copy, workspace, budgets, pause and stop state; only the conversation is its own, and it is stored with the transcript
(agent_messages, V18: the column thread = the handle, null for the agent's own rows; the console does not show it, the export of the run does). Limits: Agent.maxSubagents (an extended attribute, 0..AgentService.MAX_SUBAGENTS = 10,
checked in AgentServiceImpl.validate; null or zero offers no tools) read by AgentRunService.getMaxSubagents(run), and the permission setting Requests to the model at once
(Permission.agentsMaxConcurrency, AgentGrant.maxConcurrency, at least 1, one when not set, unlimited for an administrator) read by AgentRunService.getMaxConcurrency(run).
-
Tools.
SubAgentTools(spawn_agent(task, context?, profile?)) is bound whenmaxSubagents > 0,SubAgentControlTools(await_agent,agent_status,cancel_agent) only when it is more than 1 (with one,spawn_agentwaits by itself). Both are implemented bySubAgentToolsImploverSubAgents, the registry of the worker (Sub= handlesubN, task, stateRUNNING/DONE/FAILED/CANCELLED, result, rounds, tokens, last action, afinishedfuture).spawn_agentcreates theSub, hands it to the runner (Worker.startSub) and answers at once whilelive() < max; the call that fills the last place is kept (SubAgents.whenSlotFree) and completes, from the thread of the sub-agent that ends, with the results of what ended meanwhile (a limit of one: its own answer). A call that finds the limit reached already (one that was cut off) waits for a place first.await_agentcompletes from thefinishedfuture, with an optional timeout. The tools areFunctionContinuations: waiting holds no thread. The system message of the agent says the limits (Defaults.AGENT_SUBAGENTS_NOTE), the results are cut atDefaults.AGENT_SUBAGENT_RESULT_CHARS. -
The loop is
Worker.subLoopon a thread of the pool, inInRequestScopeof the sameAgentRequestAttributes: system message of the agent plusDefaults.AGENT_SUBAGENT_NOTE, the task as the first message,InferenceService.roundwithsubTools(the commands, the file tools and the manual tools of the agent - no run tools, checkpoint tools, sync or image tools, and no sub-agent tools, so no sub-agent can spawn), the calls one after another, a round without calls is the answer. At mostDefaults.AGENT_SUBAGENT_MAX_ROUNDS, and the limits of the run (maxRounds,isBudgetExceeded, the context cap - no compaction) fail it with a reason; every request is added to the run (addUsage).checkSubruns before every request, call and poll: a cancel or a run that was thrown away ends it asCANCELLED(SubStopped),interrupts,SubAgents.suspendand a run that isPAUSING/PAUSED(orSTOPPINGtowardsSTOPPED_KEPT) end it asSUSPENDED, any other status that is notRUNNING/WAITING_APPROVALasCANCELLED. -
Requests.
Worker.asktakes a place of the run'sRequestSlots(the cap above; a FIFO queue likeAIGate, in memory) and then theAIGateplace of the AI, for the agent and the sub-agents alike (Waiterabstracts how each waits: the agent reacts to a pause at once and a forced message, a sub-agent tocheckSub). A sub-agent that waits for a turn is cancelable. -
One writer. SQLite can fail the second writer at once (
SQLITE_BUSY; read-write transactions begin with the write lock now, see Database, but the lock keeps the changes of a call in order), and the sub-agents are threads of one run. SoWorker.changeLock(a fairReentrantLock) is held by every call thatchanges(call)(not the run tools, not the reading commands, not the sub-agent tools), by the automatic checkpoint, and by the small writes of the run (append,appendAnswer,addUsage,compact). A wait for it is pollable (lockChanges,checkSub). A sub-agent that changes something setsdirty, so the next round of the agent gets its automatic checkpoint. The status changes of anASKapproval are still not under the lock (they run on the thread of the call). -
End.
stopSubAgents(wait up to 30 s: they use the session of the run) runs when the worker ends, before acheckpoint_restore(they work in the book that is replaced; the agent is told) andfinish/ask_usercancel them; a plain reply while some work getsDefaults.AGENT_SUBAGENTS_STILL_WORKINGonce. It cancels them, except when the run is paused or stopped to be kept (waitsForResume): then it only callsSubAgents.suspendand they stop where they are. -
Stored conversations.
AgentMessageService.startThread(first row:threadTask,threadCall= the id of thespawn_agentcall),appendToThread,appendAnswerToThread(with the tokens of the request, soSubThread.rounds()/tokens()are read from the rows),endThread(a row withthreadEnd=DONE/FAILED/CANCELLEDwhose text is the answer or the reason; the first end wins),endOpenThreadsandthreads(theSubThreadrecords). A conversation without an end row is one that goes on. The ordinal counts per thread, and every query of the agent's own transcript (findAllByRun,countByRun,findLatestByRun) asks forthread IS NULL, sotranscript,count,compact,rewindand the checkpoints'after/keepare unchanged; onlydeleteForRunreads every row.rewindmarks the rows of the sub-agents whosethreadCallis a call of a rewound row, andCheckpointService.restoreends the open ones asCANCELLED(the book is another one).endSubstores the end under thechangeLockand the session of the run (it runs inside theInRequestScopeofrunSub). -
Folder.
Sub.folder()=/tmp/<handle>(VirtualFileSystem.SUBAGENTS).Worker.subTools(sub, model)builds the tool set of each sub-agent oversubFiles.scopedTo(folder): aVirtualFileSystemwith awriteScope, whosewritableMountForrefuses (... is read only for you, you can change only /tmp/subN) every write, delete, move, copy, unzip and folder creation outside it; clearing, restoring and copying the whole workspace are refused too.startSubcallsprepareFolder(sub, true)(deletes what a former run left, creates the folder), a resumed sub-agent onlyprepareFolder(sub, false);endSubof aDONEsub-agent callssaveResult(result.md,Sub.resultFile(); a failure to write is logged and the cut result then says to write long results to the folder) beforepool.finish, so the waiting call sees it;restoreSubAgentsfinds the file of a finished one again.subPayloadaddsYour folder: ...to its system message (Defaults.AGENT_SUBAGENT_NOTE). The tool set is per sub-agent, theSubModel(cached per role) holds the commands andtextOnly. -
Models. A sub-agent runs on a profile of
Agent.modelProfiles:spawn_agent'sprofileis a role, checked byWorker.subProfile(null is the main profile, refused with the list of the allowed roles whenAgent.allowSelfSubagentis off; an unknown role is refused the same way) beforeSubAgents.create(task, callId, role).Sub.profile()(null = main) is stored on the first row of the thread (AgentMessage.threadProfile,startThread(..., profile, first)) and brought back bySubThread.profile()/SubAgents.restore, so a resumed sub-agent continues on the same model.Worker.subModelOf(role)builds aSubModelonce per role (resolved profile,InferenceService, the commands,AgentService.contextCap(agent, resolved),TokenLimits.responseTokens): the commands are those ofAgentModelProfile.commandsintersected withAgent.allowedCommands(null = the agent's own), no tools at all for a profile withtools == false; the file tools and the manual tools are not commands and stay. The loop asksask(inference, profile, subPayload(conversation, model), model.tools(), ...)(the main model is read again each round like for the agent, the others are the ones built at the start) and the limits note of its system message is the model's own (limitsNote(contextCap, responseCap)).Worker.modelsNoteis the system section Models for sub-agents (roles, descriptions, commands; none when there is nothing to choose),spawn_agentis bound only whenmaxSubagents > 0and the agent may use its own model or has another one.AgentTelemetry.profilenotes the role of the model that answered on every answer (null on older rows);AgentDiagnostics.SubAgent.modeland the label ofAgentSubAgentsViewshow it. Not done: usage kept per profile in a table of its own (it is computed from the telemetry), approvals of a sub-agent's calls are the run's (Worker.approve, one queue, the sub-agent's call waits; the window does not name the sub-agent;AgentSubagentsTestcovers approve, refuse and pause). -
Pause and resume. A paused sub-agent ends as
State.SUSPENDED(finished()completes, nothing is stored); a call of the agent that waits for it (await_agent,spawn_agent, the report ofSubAgentToolsImpl) returns the sentinelSubAgentToolsImpl.SUSPENDED, which the loop does not store: the rest of the round is left unanswered too (the stable point pauses) and apause at oncethat cuts such a call off does the same. A sub-agent that is cut off between its calls has the open calls answered (AGENT_PAUSED_CALL/AGENT_PAUSED_SKIPPED_CALL) before it ends. When the run is resumedWorker.workmakes the tools, thenrestoreSubAgents(true)(the stored threads becomeSubs throughSubAgents.restore, the ones without an end continue with their conversation, after the unanswered calls of a crash gotAGENT_INTERRUPTED_CALL) andresumeParked: when the first unanswered call of the last round is aWAITING_TOOLScall it is made again (spawn_agentfinds its sub-agent byWorker.resumedCallIdand does not start another one) and the calls after it (never made) are answered as skipped; only then doespreparego on.tools()itself restores nothing (describe, for the context view, makes tools too): onlyworkandrestoreForAgent(restoreSubAgents(false): everything is over, the agent can ask) do.openCallslooks only at the results after the last message with calls: a model may use the same call id again in another round.
Not done: the conversations of the sub-agents in the console, another profile (profile argument) for a sub-agent, images for a sub-agent, message_agent, a sub-agent that survives a stop that discards.
What the model is told
The model gets, in every request, a system message built by AgentRunnerImpl.Worker.payload from these parts, in this order, and the transcript after it.
The system message is never stored: it is made again for every round, so a change in Defaults reaches a run that is resumed.
The first message of a run is a user message too: Defaults.DEFAULT_AGENT_START_MESSAGE (work now; the task is in the instructions) or, for a run
started from the console, AGENT_CONSOLE_START_MESSAGE (do not work yet, call ask_user at once). A run that goes on after a pause with the last
word being the model's gets DEFAULT_AGENT_RESUME_MESSAGE; a call that was in the air when the server stopped is answered with
AGENT_INTERRUPTED_CALL.
The descriptions of the commands are part of the prompt in practice: a model decides what to call from llmDescriptionText and the
descriptionText of the parameters, see @file:/path or @result (it was in every one, more than half of the size of the definitions of all the tools, 20k tokens for the whole set): AGENT_RUNNER_NOTE in the system prompt says it once.
Compaction
/compact [focus] (Worker.compact) asks the model of the agent, with the transcript so far as the history and AgentService.getCompactionPrompt(agent) (the agent's, else UserSetting.defaultAgentCompactionPrompt,
else AppSettings.defaultAgentCompactionPrompt, else Defaults.DEFAULT_AGENT_COMPACTION_PROMPT: the chain of the system prompt, edited in the agent dialog, the Templates tab of Settings and the Agents tab of Admin) as the last message,
plus the focus, with the tools declared and no tool wanted: a request through ask, counted with addUsage. fit first leaves out the oldest messages, whole rounds and never the newest, until the history
and the system message take at most 80% of the context cap, so a run that stopped on a full context can still be compacted (Defaults.AGENT_COMPACTED_TRIMMED tells the summary how many were left out). The summary text gets the
plan appended (Your plan when this summary was written): update_plan calls are in the compacted rows, and the plan of a worker that begins comes from plan.json. AgentMessageService.compact marks every row of the run
compacted (extended attribute of AgentMessage, no migration) and appends the summary as a row with summary; transcript leaves the compacted rows out, messages and count keep them, so the console shows everything
(the summary as a folded block) and the Markdown export keeps it. A failure (no model, no text, a tool call instead of text) changes nothing and is written to the transcript as a message of the author. The runner compacts by itself too: after the results of a round (and when the agent did not ask) compactAutomatically checks the context of the last request against Agent.autoCompactPercent of the cap (null is Defaults.AGENT_AUTO_COMPACT_PERCENT = 90, zero never, valid values 50 to 99 in AgentService.save) and runs the same compact with Compaction.AUTOMATIC (the note Defaults.AGENT_AUTO_COMPACTED_NOTE; no warning is added on top). A failure is written to the transcript as a message to the agent and the next try waits AUTO_COMPACT_RETRY_ROUNDS (3) rounds. The number lags by one round like the warnings, so a round with a huge result can still reach the cap and pause the run at the top of the loop.
The agent is warned instead and compacts itself. After the tools of a round are answered (and finish / ask_user did not end the turn) the worker either runs compactForAgent (the model called
request_compaction: compact(focus, transcript, true), the note of the summary is Defaults.AGENT_SELF_COMPACTED_NOTE, the focus line says "You asked", ignoreForce as for /compact; a failure is written to
the transcript as a message to the agent) or warnAboutContext: when the context of the last request (Progress.contextTokens, so without the results of this round) is at least
Defaults.AGENT_CONTEXT_WARNING_PERCENT (75) of contextCap it appends a USER message, Defaults.AGENT_CONTEXT_WARNING, from AGENT_CONTEXT_CRITICAL_PERCENT (90) AGENT_CONTEXT_CRITICAL. One notice per
round while above the threshold, none without a cap. The numbers lag by one round, so what one answer can add is limited too: limitCalls drops the repeats of a NO_CHANGE_TOOLS call (same name and arguments) from the answer and keeps at most Defaults.AGENT_MAX_CALLS_PER_ROUND (32) calls, before the answer is stored, so no result is owed for the dropped ones; the agent gets Defaults.AGENT_TOO_MANY_CALLS after the results. (A Gemma 4 run wrote 773 get_run_status calls in one answer, 119k tokens of results.) Defaults.AGENT_RUNNER_NOTE tells the agent about both. Agent.batchMode (an extended attribute, AgentBatchMode, null is BATCHED; the Calls in one answer box of AgentDialog) says what limitCalls keeps: BATCHED_IGNORE_POLICIES all the calls, NOT_BATCHED the first (notice Defaults.AGENT_ONE_CALL_NOTICE), BATCHED all but the answer ends before a call of a command whose @Command(batch = BatchPolicy.SINGLE) (the first call is then the whole answer; notice Defaults.AGENT_SINGLE_CALL_NOTICE). The agent is also told up front, in a section of its system message (Worker.callsNote: Defaults.AGENT_ONE_CALL_RULE for NOT_BATCHED, Defaults.AGENT_SINGLE_CALLS_RULE with the sorted names when BATCHED has commands that are alone, nothing for BATCHED_IGNORE_POLICIES; the same text every round so the cache of the server stays hot), because a model that is only told after the fact writes the dropped calls again and wonders why. The policy is advice about use and not a claim about effects like readOnly; CommandDefinition.batch carries it (a built-in tool declares it with @LLMFunction(batch = ...), FunctionDefinition.getBatch; request_compaction and the console command compact are SINGLE, they make a model call), the worker collects the names with a SINGLE overload and the tools that are (singleCalls) and the commands table of the dialog shows it in the Batch column. The commands that make a call to a model are SINGLE: next, swipe, regenerate, regenerate-summary, create-meta-summary and the generating commands of the Lore Generator and Arc Outline plugins. A call that fails ends the answer in the worker loop (agent and sub-agents): the calls after it are not run and get Defaults.AGENT_AFTER_ERROR_SKIPPED_CALL as their result (it starts with Error: this call , so AgentDiagnostics counts them as cut off and not as errors). ManuscriptReadSupport.ref takes the handle out of a result passed whole (@result of create-part is a JSON object) so a handle parameter accepts it.
Going in circles. limitCalls works inside one answer; RepeatDetector (domain.service.agent, one per worker and one per subLoop) watches the rounds. After the results of a round that changed nothing (no call with changes(call), none of SUBAGENT_TOOLS, no live sub-agent, not a replayed round) the loop records the calls and results: the fingerprint is a SHA-256 of every call's name, arguments and result, and the verdict counts the same fingerprint among the last Defaults.AGENT_REPEAT_WINDOW (15, so cycles of 2 or 3 different rounds still reach the threshold) recorded rounds. Any other round resets it. WARN (the AGENT_REPEAT_NOTICE_AFTERth = 3rd) appends Defaults.AGENT_REPEATING_NOTICE as a user message; STOP (the AGENT_REPEAT_STOP_AFTERth = 5th) appends AGENT_REPEATING_NO_TOOLS and sets textOnly, so the next pass of the loop (stable point, limits and queued messages as always) asks with ask(..., noCalls = true): InferenceService.round(payload, protocol, functions, noFunctionCalls, callback), whose default offers FunctionSet.empty() and which OpenAICompatibleInferenceService overrides to keep the definitions and send tool_choice: none (the earlier calls are in the conversation, and the prefix of the request, and the cache of the server, stays the same). Calls in that answer are dropped (Round.withoutCalls, a server that ignores the ban), an empty text becomes AGENT_REPEATING_NO_ANSWER, and the worker pauses with AGENT_REPEATING_PAUSED (needsAttention) and clears the detector; no endTurn, since the turn did not end well and the author must look. In subLoop the same text-only answer is returned as the sub-agent's answer, prefixed with AGENT_REPEATING_SUBAGENT. finish/ask_user are checked first, so a proper ending always wins. Not done: other providers than the OpenAI-compatible one (Google Gen AI gets the default, no tools in the request; whether Gemini accepts earlier function calls without tool declarations is not tested).
Live answer. The request of the agent is streamed, but the round is delivered whole (InferenceRoundCallback.onRound). InferenceRoundCallback.onChunk(ChunkType, text) (a default that does nothing, called by OpenAICompatibleInferenceService.round for every reasoning and text delta; an exception of it is dropped) lets the worker keep a LiveAnswerBuffer per run (AgentRunnerImpl.liveAnswers; domain.service.agent, bounded to the last 100 000 characters of each part, in memory only). Worker.request calls begin() before it asks, ask(..., live, waiter) is given the buffer by the main loop only (sub-agents and compact pass null), a failed or cancelled request end()s it, and the loop end()s it in the finally after appendAnswer, so the answer is on the screen until it is in the transcript; the worker's finally removes the buffer. AgentRunner.live(run) (a default returning null) is a snapshot LiveAnswer(reasoning, text), null when no request goes on. AgentConsoleDialog.showTranscript reads it on every refresh (the buffer tells AgentActivityService of every chunk, see Activity): an AgentLiveBubble (heading, the reasoning in an open Details, the text as Markdown, updated in place, so a reader who folded the reasoning keeps it folded) sits after the transcript and before the queued bubbles (liveGone, liveNew and regroup take the queued bubbles off and put them back after it), is removed when a message of the run arrives or the live answer is gone. Not done: the arguments of a call that is being written, sub-agents (their requests have no buffer).
The context of a run taken apart
AgentRunner.context(run) returns an AgentContext (domain.service.agent): what the model reads with its next request, part by part, for the Context tab of the console and for debugging. AgentRunnerImpl.context checks that the run is the user's (AgentRunService.findForUser) and lets a Worker that is never started describe() it: it opens the session of the run (openSession, InRequestScope), reads the run and its agent with the same loadAgent / loadModel the loop uses, and builds the parts from the same code that builds the request: systemParts() (the sections of the system message; payload joins the same list with a blank line, so a test compares the two) and tools() (the definitions, one part per FunctionDefinition: its description and pretty printed schema, counted as toolTokens counts them). The conversation is coalesced(transcript(run)), the messages as the request is made of them, so what the model reads is what is listed; the images are not read (the context shows their file names) and a resume's additions (prepare) are not made. Nothing is written, no worker, request slot or checkpoint is touched. tools() failing (the working copy is gone) leaves one TOOL part with the reason instead of failing the call.
An AgentContext.Part has a Kind (SYSTEM_PROMPT, INSTRUCTIONS, NOTE, TOOL, SUMMARY, USER, ASSISTANT, RESULT), a title, the exact text and its tokens (InferenceService.countTokensApprox, a quarter of the characters for a run whose model is gone). history is a Point per answer: the contextTokens the model reported (AgentMessage.contextTokens, compacted rows included, so a compaction shows as a drop) and estimated, what the context was made of at that answer by Kind. describe() makes it by replaying the rows in order (Worker.replay): the prompt, note and tool tokens are the current ones (nothing stores the old ones), a row adds its message (content and the name and arguments of its calls), a summary row replaces everything before it, and the rows a rewind marked leave at the next row that was kept (the rewound rows are marked, not dated, so a compaction that was rewound is not brought back). It is O(rows squared) in the copies of the live list and counts every row once; nothing is stored per answer. reportedTokens is the last reported size. toMarkdown() ends with a table of the points when they have estimates. toMarkdown() is the whole context as one document, each text in a fence longer than any run of backticks in it (AgentTranscriptExport.fenced).
Diagnostics of a run
AgentRunner.diagnostics(run) returns an AgentDiagnostics.Report (domain.service.agent): what the run did, counted from the rows AgentMessageService.messages(run) and threads(run) hold, for the Diagnostics tab of the console and for tuning the tools and the prompt from a real run. It needs nothing but the stored rows, so it works for a run of any state, an old one too, and writes nothing. AgentRunnerImpl.diagnostics checks that the run is the user's. AgentDiagnostics.analyze is a pure function (the tests build the rows by hand).
Telemetry. What the runner knows about a request is stored with its answer, so the analyzer does not have to guess from the gaps between the rows: InferenceService.RoundUsage carries a RoundStats (OpenAICompatibleInferenceService.RoundStatsCollector fills it while the answer streams: the time to the first chunk and to the last, the finish reason, cached_tokens and reasoning_tokens of the usage, and the timings llama.cpp adds to its last chunk; -1 or null is not known, a provider tells what it tells), the runner adds the wait for a place (Round.waitMillis, measured in Worker.ask) and the calls the answer had against the ones that were made, and stores it as the JSON AgentMessage.telemetry (AgentTelemetry, an extended attribute: no migration, travels with backups) with AgentMessageService.appendAnswer / appendAnswerToThread / compact (the summary row has the one of the request that wrote it). AgentDiagnostics.Telemetry adds it up (rows without it are left out, rounds says how many had it).
A round is an answer of the model with its calls; a call is matched to its result by the id of the call. The time of a step is the gap between the creation time of its row and of the row before it: the request for an answer, the call for a result (the runner stores each result as the call ends). A gap of IDLE_MILLIS (30 minutes) or more is idle. The report has the totals (Overview), the statistics of each tool (ToolStat: calls, errors, cut results, rounds the tool was alone in, characters written and returned, time), the Series (three or more rounds in a row that called one tool, afterSearch when a search round came right before), the calls made again, the error messages (numbers replaced by N, with the start of the arguments of the first call that got each), the biggest results, the slowest rounds, the compactions and the sub-agents. An error is a result that starts with Error:, except the ones of a call a pause, a stop or a message of the author cut off (Error: this call ...), counted apart as interrupted. The notices of the runner are recognised by the texts in Defaults. The arguments of every call are read as JSON: a text argument that starts with @file: or is an @result reference counts as a reference, any other string of LONG_TEXT (300) characters or more as a long text written out, and such a text is a copy when a sample of 80 characters from its middle is in one of the 60 results before the call (what the model had just read). A call cut off in the middle (invalid JSON) counts what is there as written. Block adds up BLOCK (25) rounds: the characters of the arguments, of the text beside the calls and of the reasoning (AgentMessage.getReasoning), the references, long texts, copies, errors and the model time, and BigArgument is a call with one of the longest arguments. toMarkdown() and toJson() are the two files the tab downloads; the tab shows the Markdown.
One AI, several runs
Every run asks under its own InferenceSession of the kind AGENT (the key is the run id, AgentRunnerImpl opens it around the loop of the run), so the X-Session-ID of a run is its own: a run grows one conversation, and the prompt cache of the provider is kept for it alone.
The same loop opens a UsageContext for the working copy of the run, so the tokens of its requests go to the writing statistics of the original book on the agents' side (AgentRunService.findOriginalOfCopy resolves the copy, whoever owns the run). findRunningBookIds lists the books of the runs in the status RUNNING (not paused, not waiting for the author): the ActivityTracker asks for it once a tick to record the agents running time. The merge of a result writes its versions as uncounted and does not count the deletes again (they were counted when the agent did them).
AI.maxConcurrentRequests (an extended attribute, the Concurrent requests of agents field of the AIDialog; null or zero is no cap) is enforced by AIGate (AIGateImpl, a singleton, state in memory keyed by the uuid of the AI): acquire(ai) gives a CompletableFuture<Permit> that is complete at once when there is room, else when it is its turn (a queue in the order of arrival, per AI); the cap is read from the stored AI at every acquire, so a change applies to the next request. Canceling the future takes the caller out of the queue and costs nobody a place; closing a Permit twice frees one place.
Two places take a permit: AgentRunnerImpl.Worker.ask (every round and every compaction; the wait is part of the round, so Interrupted / Forced / a discard and an ordinary pause or stop end it) and the commands that start a model on behalf of an agent (ManuscriptWriteSupport.placeAt(context, ai), used by /next, /swipe, /regenerate and the summary commands; it gives a no-op permit to the user at the command bar, who is not queued). The stream of the story window is not gated: the stream ends in several asynchronous places, and an agent never gets a place the user holds. The cap is therefore about the requests of agents only; a user who generates by hand on a one-slot server still shares it with them.
Checkpoints
A checkpoint is a restore point of the working copy and the workspace of a run. CheckpointStore (domain.service.agent, only files, not synchronized) keeps them as checkpoints/NNNN.zip in the run folder: the number is given
by the code (the next one, never the agent), the archive holds manuscript.json (ManuscriptService.snapshotAgenticCopy: the backup of the copy with its lorebooks and the uuids of their entries, the format of the base),
pairs.json (AgentRunPairService.getAll, with the handles: UuidPair has a handle), workspace/... (WorkspaceMount.snapshotTo) and meta.json (kind, label, round, workspace size). A checkpoint is first written
to a temporary file (Pending) and only commit() gives it its number, so a refused one leaves nothing. The size of the archive is what the quota counts. Kinds: START (one, pinned), AUTOMATIC (the ring of RING = 5),
MANUAL.
CheckpointService (CheckpointServiceImpl) holds a lock for each run and decides about the quota, which is AgentGrant.maxCheckpointBytes (the permission setting Checkpoints of a run (MB), an extended attribute of
Permission) or DEFAULT_QUOTA_BYTES (100 MB). START must fit the quota. AUTOMATIC is never refused for the room that other automatic ones take: ringDrops lets the oldest ones go (beyond the ring, and more while the quota
is exceeded), never the start or a manual one; it fails (CheckpointFullException) only when manual ones took everything. MANUAL fits when used + size + reserve <= quota, where the reserve is the room for the ring to fill up
(and for the start, if missing) at the size of the newest checkpoint; it fails with CheckpointFullException, whose message is written for the agent (needed / free, the list, "delete with checkpoint_delete", or that deleting
does not help). Nothing is ever deleted to make room for a manual one. delete(run, id, byAgent) refuses the start and the newest for the agent.
Rewind (CheckpointService.rewind, @CommonTx, only for a PAUSED or STOPPED_KEPT run, so no worker holds a transcript in memory) is a restore with the conversation: every checkpoint stores the place in the transcript it was made at in its meta.json (after, the uuid of the last row that is part of its state, Info.after; AgentMessageService.lastRow; the automatic checkpoint of the runner is made after the answer that asks for the changes, so it passes forgetLast = 1 and a rewind forgets that answer too; one without after was made before the rewind existed). A place is a row uuid and not an ordinal so that a checkpoint of an undone conversation recognises itself: its row is rewound now and rewindable leaves it out, while the ones made after the rewind stay valid. rewind restores first (its safety checkpoint is made with the whole conversation), then AgentMessageService.rewind(run, keep) marks every row from keep on rewound (an AgentMessage extended attribute like compacted; transcript and getContextTokens skip them, messages and the export do not), un-compacts the rows a compaction after the cut had replaced (the rows before the last kept summary stay compacted), answers the calls of the last kept round that have no result with Defaults.AGENT_REWOUND_CALL (a checkpoint made in the middle of a round by checkpoint; the results of a round are stored one by one, so the ones before it stay) and appends Defaults.AGENT_REWOUND as a user message. The plan is read from the transcript on resume, so it is the plan of the kept conversation. The run is not resumed; the console greys the rows (CssStyles.AGENT_REWOUND) and shows the transcript again from its start (AgentConsoleDialog.rewound).
Rewind to a message (CheckpointService.planRewindToMessage / rewindToMessage; the same private rewound(run, info, keep) as rewind, so the same restore, marking and notice) is what the console offers under every user row (AgentConsoleDialog.rollbackButton, enabled in IDLE, PAUSED and STOPPED_KEPT; an IDLE run is paused first, rewindToMessage itself needs PAUSED or STOPPED_KEPT). The target is the checkpoint with the largest keep (the number of rows that stay: ordinal + 1 of its after row) that is at most the ordinal of the message; the checkpoint of the start with no after counts as keep 0. State is only known at checkpoints, so the rows between that checkpoint and the message are forgotten with it (RewindPlan.forgotten counts them for the confirmation); a checkpoint whose place was rewound over is skipped like in rewindable, a message that was rewound over or has no checkpoint before it is refused with a CheckpointException.
Restore (restore, @CommonTx) first makes an automatic checkpoint of the state it leaves (protectedId: the ring does not push out the one being restored), then
ManuscriptService.createAgenticCopyFromSnapshot makes a new copy from the snapshot (the shared part of createAgenticCopy; the pairs it reports name the uuid the item had in the snapshot as its original),
remap turns those into the real pairs (the original and the handle the item has now, else those in pairs.json; the rows of handles of items that did not exist at the checkpoint stay without an item, so a handle of the
transcript points at nothing and is never given again), AgentRunService.replaceWorkingCopy points the run at the new copy, stores the pairs and deletes the old copy, and last VirtualFileSystem.restoreWorkspace
(WorkspaceMount.replaceFrom) unpacks the archive next to the workspace, checks every name like a name the model writes, and swaps the folders; a failure leaves the workspace as it was and rolls the database back.
AgentVirtualFileSystemService.deleteCheckpoints is called by releaseCopy, so the checkpoints go with the copy.
In the runner (Worker): startCheckpoint before the loop (ensureStart; a resumed run that has checkpoints is dirty), and before the calls of each round checkpointBeforeChanges: when the round contains a call that
changes (every tool except the ones in NO_CHANGE_TOOLS and the commands with @Command(readOnly = true)) and the worker is dirty (something changed since the last checkpoint), an automatic checkpoint is made; after the round
dirty |= changes. A checkpoint that does not fit does not stop the run: the agent is told once (Defaults.AGENT_NO_AUTOMATIC_CHECKPOINT, after the results of the round, never between the call and its result). The tools are
CheckpointToolsImpl: checkpoint, checkpoint_list, checkpoint_diff and checkpoint_delete are done at once; checkpoint_diff is CheckpointService.diff(run, id, path) (a Difference of the checkpoint, the book as a MergePlan made by MergeDiff.between, the files of the workspace and, for one changed text file, a TextDiff.Result), written down by CheckpointText.difference. The book is compared as two plain snapshots (the checkpoint's manuscript.json and snapshotAgenticCopy now) with the items keyed by the uuid of the original, and for an item the agent made by its handle (keysOf from the pairs of each side), so a restore, which makes a new copy with new uuids, does not show everything as deleted and added; the workspace is compared by size and the CRC-32 the ZIP entry already holds, nothing is unpacked. With a path only the workspace is compared; checkpoint_restore only notes the id, and the worker restores after the round (restoreForAgent), makes the tools again (the
scope of the commands holds the copy) and tells the agent what came back (Defaults.AGENT_RESTORED, the files removed, the checkpoint of the state before). The console tab is AgentCheckpointBrowser.
Merge and apply
What a run changed is taken over by AgentMergeService (AgentMergeServiceImpl), for a run that is DONE or STOPPED_KEPT (it has its copy). The text it writes into a part is a AUTOMATIC version of the history with the detail AGENT_MERGE and the agent flag. plan(run) returns a MergePlan, apply(run, MergeSelection) applies what
the user picked. The book is never restored from a backup (that would change the uuid of every part): the items are changed one by one, in place.
The comparison is MergeDiff (domain.service.agent), plain code on JSON without the database. Its three inputs are snapshots in the format of ManuscriptService.createBackup plus the lorebooks with the uuids of
their entries: the base (AgentVirtualFileSystemService.readBase, the book as the run started), the copy (ManuscriptService.snapshotAgenticCopy) and the book now (createBackup + marshalLorebooks(root, true)).
The copy is read through the pairs of the run (AgentRunPairService.getAll, copy uuid -> original uuid): every uuid of the copy, also the ones inside a text (nextSummaryUuid, replacedSummary, the data of a plugin;
MergeDiff.translate), becomes the uuid of its original, and an item without a pair gets the key new:<uuid in the copy>. Each item is a map of properties (a part: response, instructions, sceneSetting,
povCharacter, presentCharacters, summary - one canonical string of the text, type, hash and the links of the summary - and every attributes_* key of the part, the JSON of the value with the uuids translated, which
holds the template variables of the generation (templateVariables) and the data of the plugins; MergeDiff.messageFields; a lorebook: name, enabled, tags, subbooks; an entry: its fields, tags and the lorebook it is in;
the book: its settings (MergeDiff.BOOK_FIELDS, with the number of versions kept per part), tags and every attributes_* key, the data of the plugins) and is compared field by field: the field the agent changed (copy differs from base) and the book did not (book equals base) is a change to
take over, both changed to different values is a conflict, the same value on both sides is nothing. SET fields (tags, subbooks) are merged by what each side added and removed and never conflict. A key only in the copy is
ADDED, one only in the base and still in the book is DELETED (a conflict when the book changed it since), the ones the user deleted from the book are problems (PROBLEM_GONE, PROBLEM_NEEDS_PARENT,
PROBLEM_NEEDS_LOREBOOK) and can't be applied. Item.dependsOn is the item that must be applied first (the new part a new part follows, the new lorebook a new entry is in); MergeDiff.chain and dependents follow it. A book that had no lorebook when the run started (the commands create-lore-entry and create-lorebook make its root in the copy, LorebookWriteSupport.loreToAddTo) gets the root of the copy as its lorebook when the apply made it (Applier.adoptRoot).
The service adds sharedWith (the other books whose lorebook tree holds the lorebook).
Annotations are the fourth kind of item (Kind.ANNOTATION, id A:<key>; MergeDiff.readAnnotations reads the annotations array of each message of the snapshot, the pairs of AgentRunPairKind.ANNOTATION give
the key). What is compared is ANNOTATION_FIELDS (text, type, status, sendToPrompt); the passage is not: it follows the text of its part, which the part's own item takes over (an
ORPHANED status counts as OPEN, the text orphans a note and not the agent). An annotation of the copy without a pair is ADDED (its fields and the quote are shown), the item dependsOn the part
when that is new too and has the problem PROBLEM_NEEDS_PART when the book no longer has the part; one the agent removed is DELETED, except where its part was deleted too (the part takes its notes). The Applier runs the annotations after the parts:
createAnnotation makes the note with AnnotationService.create on the same offsets when the part has the same text on both sides, else createFromQuote with the quote and its context (a text the apply
just wrote is the same on both sides), keeps the source, the flag and a closed status, and records the pair; changeAnnotation uses update / setStatus (an agent does not reopen a note that lost its passage);
deleteAnnotation uses remove. BaseAdvance moves the base for an annotation the sync took over (it adds it to the message of the base, or copies the changed fields). The sync of a resume brings the notes of the
user into the copy the same way (the notes are not locked while a run holds the book), with handles given on demand (HandleTranslator knows a; CommandScope.resolveAnnotation recognises an annotation the agent made). The dialog lists the kind as
ENUM_DOMAIN_ANNOTATION, counts it in MSG_MERGE_SUMMARY, has the button Only annotations, and Show in the copy opens the part (AgentMergeDialog.showAnnotationPart). Tests: AgentAnnotationsTest.
JSON data (the attributes). What extensions keep in attributes of a book, a part, a lorebook or an entry is JSON, so a field named attributes_<key> has the type Type.JSON (editable); its base, agent and book are the
compact JSON with the keys sorted (JsonText.compact: the order of the keys is no change; empty for no data, uuids of the copy translated). A book and a part have it in extendedContent (attributes_<key>); a lorebook and an
entry as an attributes object in the snapshot, which LorebookServiceImpl.marshalLorebooks writes when it is not empty, in every variant (so the working copy, the checkpoints, backups and exports carry it) and
importLorebooks / unmarshalContent read; MergeDiff.lorebookFields / entryFields / messageFields add a field for each key. A JSON field is a conflict only where both versions changed the same place
(JsonMerge.conflicts), not whenever both changed the data, and when the user wrote nothing the applier takes JsonMerge.merge(base, agent, book) (Applier.value), the three-way merge, in the sync too (there the book is the changed
side). The text of MergeSelection.edits wins and is read strictly (JsonText.parse): text that is not JSON throws InvalidJsonException and the apply is rolled back. Applier.setAttributes writes the keys into a lorebook, an entry
(saved again, LorebookEntryService.update copies no attributes) and a created part; BaseAdvance.copyAttribute moves the base for the attributes of lorebooks and entries.
The JSON merge (domain.service.agent.jsonmerge, plain code without the database, JsonMergeTest):
JsonMergeDialog is its screen: a TreeGrid over an AbstractBackEndHierarchicalDataProvider (the children come from visibleChildren, so only the opened places are made), a toolbar that acts on the selected place, a breadcrumb (the open
place, level, is the root row of the grid), the Only what changed filter and the Text tab (a TextArea of the agent's data that is read strictly in commitText when the user leaves the view, opens another place or takes the result,
and WordDiffView for the difference to another version). It never touches the database: AgentMergeDialog.jsonView keeps one JsonMergeNode for each item and field (jsonModels, cleared by load), so reopening shows what was
decided, and stores what the user takes as an edit (dropped again when it is the plain merge). The styles are json-merge-*.
Apply runs as two transactions (a TransactionTemplate of the common manager, apply has no @CommonTx): the changes, all or nothing, and then AgentRunService.transition(run, APPLIED), which deletes the copy, the pairs, the
base and the checkpoints (the 2000-part measurement: 150 changes on a book of 2000 parts of 4 KB: the plan 0.2 s, the changes and the delete of the copy a few seconds each, the whole apply 5-9 s; a restore is 30 s because it also
makes a copy). The plan is made again inside the transaction, so the picked ids are applied to the book as it is at that moment, and an item whose prerequisite is not picked is skipped (Skipped). A backup of the book
(BackupService.takeBackup) is taken first. The order: the settings of the book (ManuscriptService.updateSettings, the plugin data into getAttributes(), the tags), new lorebooks, changed lorebooks and their subbooks
(addSubbook/removeSubbook with the cycle check), new / changed / deleted entries (LorebookEntryService.create/update/move/deleteEntry), deleted lorebooks, then parts: new ones in the tree order
(ChatMessage.copyOf + createRoot/addChild), changed ones (the text through ChatMessageService.storeResponse: tokens and words are counted again only for a text the user wrote, the agent's text comes with the counts
of its part), deleted ones (deleteNodeAndMigrateChildren, to the trash), and the summaries (the summary a part already has is written in place so that meta summaries still find it by uuid; the links inside new
ones are translated at the end). The picked text of the user comes in MergeSelection.edits (item id -> field -> text), only for the editable fields. The services are called as for an edit by the user (their events are
sent). Anything that fails rolls the changes back and the run stays DONE; if only the second transaction fails the changes are in and the run still waits, discarding it ends it.
Sync on resume is the same comparison with the roles swapped. MergeDiff.computeSync(base, book, copy, pairs) takes the book as the side that changed and the copy as the side that takes it over (Item.sourceUuid / targetUuid are the item in
the book and in the copy; in a review they are the copy and the book), and the Applier works on a Direction (source book, target book, the pairs in the direction needed, and how a key of the plan becomes a uuid of the target). AgentRunServiceImpl.resume
calls AgentMergeService.sync(run) before the status changes. The base is not advanced as a whole, so that the review still sees what the sync did not take over; BaseAdvance moves it only for what was taken over (the new items are added to it, the fields
changed get the value of the book), otherwise a later change of the agent to a value that came from the user would look like a change both sides made. A field both changed (Field.conflict) is left to the copy and reported; an item the copy lacks
(PROBLEM_GONE, PROBLEM_NEEDS_PARENT) is a conflict line too. The items the sync creates in the copy are recorded as pairs (Applier.created), so they have handles, and BaseAdvance and the pairs are stored in the same transaction as the changes (the base file
is replaced when it commits: AgentVirtualFileSystemService.updateBase). The result is a SyncReport (lines by handle, text() for the model); it is written to base/sync-report.txt with a base/sync-pending mark
(writeSyncReport, after the commit), the runner's prepare takes the mark (takePendingSync) and puts the report in front of the model as a user message, with the "go on" sentence, and the tool get_sync_report (SyncTools, offered only while the run has a report) reads the file. A resume of a run
whose book did not change takes 0.1 s and writes nothing; one with a hundred changed parts of a book of 2000 parts takes about 3.5 s. A lorebook the sync makes in the copy is agentic.
The screen is AgentMergeDialog: a multi-select grid of the items beside the detail of the one clicked (the text of the book and an editable box of the text to apply, the added and removed values of a set), opened from
the Agents tab and the console (Review changes). It keeps the picks consistent with dependsOn and refuses to pick a problem item.
Per field. MergeSelection.skippedFields (item id -> field names, the fourth component; the three argument constructor leaves it empty) names the fields of an item the user did not tick. AgentMergeServiceImpl.withPickedFields removes
them from the CHANGED items before the Applier runs, so the applier needs no change (the sync builds items with fewer fields the same way): ADDED and DELETED items are taken whole, MergeDiff.PARENT is never left out (a part is not taken
over without its place), and a CHANGED item left with no field but parent is dropped (not counted, not "skipped"). The dialog shows a Take this change box per field only for a CHANGED item with more than one field other than parent.
The text diff. utils.WordDiff is the difference of two texts in words and white space (diff(before, after) -> Segment(kind, text) of SAME / ADDED / REMOVED, adjacent ones joined): the common start and end are cut off, the rest is the shortest edit
script of Myers (MAX_EDITS = 1500 changed units; beyond that the middle is one removed and one added segment, so two texts with nothing in common cost nothing). Where two scripts are equally short, which one is taken is arbitrary (a shared space can split a change in two): it
is always a correct script (both texts can be read back from it), only not always the prettiest. It is not utils.TextDiff, the sentence diff of the compare-parts command, which has no inline form. ui.widgets.WordDiffView shows the segments as Spans (the
classes merge-diff*). AgentMergeDialog.differences shows book now -> the box (live), and for a field with a conflict also base -> agent and base -> book. It compares only TEXT fields the user can edit.
Reading the copy
AgentCopyService (AgentCopyServiceImpl, @CommonTxReadOnly) reads the working copy of a run the user owns (agentRunService.findForUser; IllegalArgumentException for someone else's run, IllegalStateException without a copy): branches(run) (the ends of the branches, oldest first,
numbered like the list-branches command), branch(run, partUuid) (the parts of the branch that shows the part: the active branch when the part is on it, else the newest one below it; null for the active branch) and lorebooks(run) (the root and its subbooks level by level, with the entries and their tags). It
uses StoryTree of the read commands (made public for it), so the numbers agree with what the agent sees. It works while the run works (plain reads), and it never opens the copy as a book: there is no window, so StoryBackup never starts and no plugin decorator sees it.
The view is AgentCopyView (the Book copy tab, AgentConsoleDialog.COPY_TAB, read when the tab is picked and by Refresh, never by the poll), with showPart(uuid) / showEntry(uuid) for the Show in the copy button of the review (a dialog over it, AgentMergeDialog.showInCopy); the item's sourceUuid is the uuid in the copy.
A part inserted into the story (ChatMessageService.insertPart, the command insert-part) is an ADDED part whose parent is the part it follows, and the parts it took over are existing parts whose parent changed. Existing parts are compared by MESSAGE_FIELDS only, so MergeDiff.messageItems adds the field parent (MergeDiff.PARENT, type REF, shown as the label of the part, DISPLAY + parent) to the CHANGED item of a part whose parent in the copy is a part the agent made, and the item dependsOn that new part. A part that only moved up because its parent was deleted (delete-part) is not matched: that is applied by the delete itself. changeMessage sets the parent from the source part (targetOf of its copy parent, made by createMessage before: the plan lists the added parts first). A user who leaves the move out gets the new part as a new branch beside the old one. A parent is not always older than its child after an insert, so nothing may order parts by id (hardDeleteForManuscript sorts by depth).
The tools of an agent
AgentRunnerImpl.Worker.tools() puts the function sets together with FunctionSet.and (a function name used twice fails the run):
CommandFunctions.forAgent(...) the commands the agent is allowed
.and(fileSystemService.getFileTools(files, results)) list_files, read_file, write_file, ..., save_command_result
.and(registry.bind(ManualTools.class, ...)) search_manual, read_manual_page (the help assistant's manual tools, read only)
.and(registry.bind(RunTools.class, runTools)) finish, ask_user, update_plan, get_run_status
.and(registry.bind(SubAgentTools.class, ...)) spawn_agent (and SubAgentControlTools with more than one), only when the agent has sub-agents, see [Sub-agents](#sub-agents)
.and(registry.bind(ImageTools.class, ...)) view_image, only when the main profile has images
A round is one request (InferenceService.round, streamed to the provider and returned whole) that answers with text, the calls the model asked for (they are not called by the
inference service) and the tokens. The runner calls them itself, one after another in the order the model wrote them, each on a thread of its pool inside
the context of the run, and appends all the results together as the next messages; a failure is the text Error: <message> so the model can correct the
call. Because the order is kept, a call may use what an earlier call of the same answer did (save_command_result right after a command).
RunTools only notes what the model asked (the report, the question, the plan); the worker acts on it after the round, so that every call of the round
has its result in the transcript first.
Images
An agent can look at images when the author says its model can (AgentModelProfile.isImages(), a checkbox of the AgentDialog for the main profile; the API of
an OpenAI compatible endpoint does not report it). Everything image related is built from three existing facts: LLMAttachment (an image on a user message becomes
an image_url content part in OpenAICompatibleInferenceService.toUserMessage), the cage (an image is a file of the workspace) and the transcript (rows must stay small).
-
Files. An image is a workspace file whose bytes start like a PNG, a JPEG, a GIF or a WebP (
ImageFiles.mimeType, never the name; SVG is text and is not one).VirtualFileSystem.writeImagechecks that (ImageFiles.check: the type, and for the types ImageIO reads the pixels from the header, at mostMAX_PIXELS, so a decompression bomb is not stored) and writes throughVirtualMount.writeImage(a default method that refuses;WorkspaceMountshareswritewith it). An image may beWorkspaceLimits.maxImageBytes(5 MB by default; the three argument constructor makes it the larger of that andmaxFileBytes), a text file keepsmaxFileBytes; both count against the total and the number of files.WorkspaceMount.readreads up to the larger limit andVirtualFileSystem.readrefuses what is overmaxFileBytesand not an image, so the text rules did not change.readTextof an image says "is an image ... view_image shows it to you". The agent can't write bytes (its tools write text), so images come from the user: the Upload button of theAgentWorkspaceBrowserand the attach button of the console, or a command result in a later version. -
What the model is sent.
ImageFiles.preparereturns an image as it is when its longest side is withinMAX_SIDE(1568) and the file within 1.5 MB, otherwise it is scaled with ImageIO in its own format (a PNG stays PNG, a JPEG stays JPEG, a GIF is its first frame as PNG, an image with alpha is PNG); a WebP is not decoded here and goes as it is.VirtualFileSystem.readImageisprepare(read(...)). -
view_image(path)(ImageTools,ImageToolsImpl) checks the image and notes the path in theShownImagesof the worker. A tool result is text only in Chat Completions, so after the results of the round the worker appends one more message of the user: the text "The image you asked to look at: /refs/map.png" and an attachment per image. The tool is bound only when the main profile hasimages(Worker.seesImages). -
The transcript keeps paths. The attachment of a stored message is
ImageFiles.reference(path)(kindIMAGE, the path as its name, no bytes, no URL;isReferencetells it). A message of the transcript with such an attachment is turned into the real image when a request is made:Worker.withImagesworks on a copy of the transcript, reads and scales the newestMAX_SHOWN_IMAGES(4) images, and writes a note into the text of the older ones (... is not shown any more, view_image shows it again), of the ones that are gone (can't be shown: <reason>) and of all of them for a model that does not see images. The images are re-read for each request, which costs a decode and keeps the stored run small and valid after a resume. -
Images with a message of the author.
AgentRunner.send(run, text, images)takes paths of image files of the workspace (at mostMAX_ATTACHED_IMAGES= 8; the text may be empty): it refuses a model that does not see images, checks each path withreadImage, appends "Attached image: /uploads/a.png" to the text and adds the references. The console (AgentConsoleDialog) keeps the picked images (attachImage, anUploadthat takesimage/*and a paste handler,JavascriptLibrary.forwardPastedImages) until Send, then writes them withwriteImageinto/uploads(uploadPath: a taken name gets-2,-3) and passes their paths. -
Commands that take images. A command parameter of the type
LLMAttachment[]is a list of paths for the model (an array of strings withDefaults.AGENT_IMAGES_ARGUMENT_HINT):CommandFunctions.forAgent(..., Function<String, LLMAttachment> images)reads the paths withreadImages(at mostMAX_IMAGES= 8; a path that is not an image is an error naming the argument) and the command getsLLMAttachment[]. Without a reader the parameter is not in the schema. The worker gives the reader whatever the profile says: the images go to the model of the command, which is not the model of the agent (generate-lore-entryof the Lore Generator is the first). In the command bar the argument is text, which can't be converted, so a form with such a parameter is not reachable there.
Commands as functions
CommandFunctions (package domain.service.agent) turns the commands an agent may call into a FunctionSet: forAgent(commandService, agent, lookup, approval) offers the commands on the allowlist of the agent, create(commandService, names, mode, lookup, approval) the ones named. lookup gives the
texts (localization::getValue; the descriptions are for the model, so build the set inside the context of the run). It is built for a run and combined with
the tools of the runner by FunctionSet.and; the arguments may hold
-
One function per command. The forms (overloads) of a command share one schema: the union of their parameters in the order of the ladder, a type that differs between the forms is "any". A call goes to the form with the fewest parameters that has every argument the model passed, the parameters of it the model left out are null or their
defaultValue; a middle option left out therefore works. Arguments no form takes, a missing required one or a value outsideallowedare anINVALID_ARGUMENTSerror that says which. A command without a declared signature takes any properties. -
Required is not declared: a parameter is required when every form with a signature has it and none marks it
optional(see writing commands). The schema carriesdescription,enum(enum types andallowed) anddefault. -
Names. A command name that is a valid function name (
[a-zA-Z0-9_-]{1,64}) is the function name. Any other is written with_for every character that is not allowed, cut so that it fits, and_plus six hexadecimal digits of the SHA-256 of the command name (functionName). The rule looks at the name only, never at what else is registered, so the name in a stored transcript means the same command after a restart (andfoo.baris never taken for a commandfoo_bar). Names are resolved by the name at every call, so a command that is gone answers "not available now". -
Result and errors. The result is bounded (
MAX_RESULT_LENGTH, 20 000 characters, cut at a line with a note how much there was): a string as it is, anything else JSON, nothing "OK". A failure is the message of the cause (no class names), which the inference service writes asError: <message>. -
Policy. Only the allowed commands exist for the model. A
DESTRUCTIVEcommand followsAgent.getDestructiveMode():DENYleaves it out,AUTOruns it,ASKasks theApproval(a future of the decision of the user; the call waits for it and no thread is held) and refuses when there is none. -
Context. The commands run as whoever the thread is: the inference service calls the functions in the request context of the caller, which for a run is the
AgentRequestAttributesofopenSession, and the command service hands it on to the commands that run on a worker. Nothing is aimed at the working copy by the adapter itself; that is what the context (theAgenticSessionPointof the run) does.
References
A long text should not pass through the model twice: the model pays for every token it writes (time, and the limit of one answer), and a text it
has already read is in its context. Two references let a call name the text instead of writing it. Both are a whole value of an argument
(@file:/notes/a.md, @result, @result:3; text inside a longer value is just text) and both are replaced before the call, so the command
(and the approval dialog) gets the real text, while the transcript keeps the short call.
CommandFunctions.readReferences does the replacing, once the arguments are known and before the form of the command is picked, and only for a
parameter that is a text in every form that has it: a number or a boolean is left as the model wrote it. A reference that
cannot be resolved is an error naming the argument (The argument text: There is no result #7, list_command_results shows the ones that are kept),
so the model corrects it. The functions are told about the references in the schema (the description of every text parameter gets the hint) and in the
runner note; @file: is a reference only when the agent has files (a files reader was given) and @result only when results are kept (a CommandResults
was given), otherwise the text is passed as it is.
CommandResults is the holder of the results: one per tools(), shared by the commands (which add to it) and the file tools (which read it).
CommandFunctions.run adds the result of every call that returned (a failure keeps nothing) with the function name, the arguments as one short
line (describeArguments; an argument that was a reference shows the reference) and the text before the model's 20 000 characters cut, so what a reference
inserts and save_command_result writes is all of it even when the model saw a cut one. The last KEEP = 10 are kept, numbers count from 1 in the run, the
holder lives as long as one worker (a resumed run starts empty). list_command_results shows them newest first, save_command_result(path, id?, append?)
writes one to a file by the same atomic write as write_file. A result is added after the command returned and before the next call is read, so the
newest is always the one of the previous call.
Files of an agent
AgentVirtualFileSystemService hands out the files of agents. Two folders in the application folder (not in data/, which is named by logins):
agent-workspaces/<agent uuid> is the workspace, which belongs to the agent and stays between its runs (the notes it keeps), and
agent-runs/<run uuid> is the folder of a run (base/ with the snapshot of the original, written when the copy is made, and checkpoints/, see findings.json, the findings of report, lies in the folder itself so it outlives the working copy; RunFolder is the layout). The uuids are
checked, so no value of the database makes a path leave these folders. open(run) is the file system of a run (the workspace, /checkpoints and /AGENTS.md),
openWorkspace(agent) the workspace alone for the user to look at; both refuse (IllegalArgumentException) what is not the user's own. Both use the
WorkspaceLimits of getLimits(agent): the defaults of the service (getLimits()) with the quotas of the permission of the agent's owner over them.
getFileTools(fileSystem) binds AgentFileTools to it (a FunctionSet, to be put with the other tools of the run). clearWorkspace refuses
while the agent works, copyWorkspace is what AgentService.cloneAgent calls, and a workspace is deleted with an agent that is deleted for good
(not with a soft deleted one: it can be restored) and by the cleanup (the service is a CleanupContributor), a run folder with its run; the
files go after the transaction committed, a rollback does not bring them back.
The model never gets a path of the disk. A string it wrote goes through three steps, all in domain.service.agent:
text -> VirtualPath.parse -> VirtualFileSystem (mounts) -> WorkspaceMount: checked real path (never returned)
-
VirtualPathis the immutable value. Parsing normalizes (NFC), requires/at the start, lets..go up and stop at the root (a chroot), and refuses empty and.parts,\, control, format and unassigned characters,< > : " | ? *, a name that starts or ends with a space or ends with a dot, Windows device names and too long names, paths and depth. That takes drive letters and UNC paths with it. Percent encoded or full width dots are letters: names like any other, leading nowhere. Every method that builds a path from a name checks the name again. -
VirtualFileSystemroutes a path to aVirtualMount(stat,list,read,write,writeImage,createFolder,delete,move,toVirtual): the workspace is the root,/checkpoints(CheckpointsMount) lists the checkpoints and is read only,/AGENTS.md(InstructionsMount, only in the file system of a run) is one read only file with the instructions of the agent asopen(run)read them. A mount is a folder or a file (mountEntries()gives how each shows at the root); a file of the workspace with the name of a mount is hidden by it. Other mounts, backed by services, can be added the same way.toVirtual(Path)is the reverse mapping for everything that goes to the model and throws for a path outside every mount. -
WorkspaceMountis where the cage is made of real checks: it walks the path name by name from the real root and refuses a symbolic link, anything but a file or a folder, and a name present in another spelling (also on a case sensitive file system, soNotes.mdandnotes.mdare never both there); a workspace folder that is itself a link is refused when it is opened. Files are written whole to a temporary file (.marginalia-tmp-prefix, not listed, not creatable) and moved over the target, so a reader sees the old or the new content. Writes and deletes of one workspace take one lock (kept by the service per agent), which makes the limits ofWorkspaceLimits(file size, total size, number of files) true under parallel threads. The namecheckpointsandAGENTS.mdare reserved at the root. -
Errors are
VirtualFileExceptions whose message names virtual paths only; the cause (with real paths) goes to the log. The tools pass nothing else to the model.
AgentFileToolsImpl implements the functions: list_files, read_file (line ranges, cut at 20 000 characters at a line with the line to go on from; a result that is the whole file and has at least Defaults.AGENT_FILE_HINT_FROM (500) characters ends with Defaults.AGENT_FILE_HINT, a line that tells the model to pass the file as @file:/path instead of writing its text out in a call: the model has just got the text, which is when it copies it, and its output is what costs the time),
write_file, append_file, replace_in_file (the old text must be in the file exactly once; a file with Windows line breaks is matched too),
regex_replace_in_file (both take dry_run, and a folder as path with glob and max_files: replaceBelow plans every text file below it - images and zip files are left alone - refuses the whole job when more than max_files (default AgentFileToolsImpl.MAX_REPLACED 100, at most 1 000) would change or VirtualFileSystem.checkWritable refuses one, answers path: n replacements per file, and writes only when dry_run is false (the default for a folder is true, for a file false); a write that fails half way puts the files back from the texts it read, so the job is all or nothing; a folder replace replaces every occurrence while a single file keeps the rule of exactly one), grep_files (at most 100 lines, 1 000 files), move_file (a free target name, the folders above it are
created; not between mounts), copy_file (a file or a folder to a free name; all or nothing, checked against the file, size, depth and path-length limits before a byte is copied, through a temporary file or folder moved into place; the same mount only), merge_files (up to 100 files, read and joined in Java, one write; a * / ? in the last name of a path stands for the files of that folder by name, the target left out), unzip_file (see delete_file. list_command_results and save_command_result work on the results of the commands, see list_files and grep_files take a glob (VirtualGlob: *, **, ?, [a-c], {md,txt}, case-insensitive, matched on virtual paths only; without a / against the name at any depth, with one against the path below the folder). The regular expressions use TextSearch and read the text through
a DeadlineCharSequence, which throws after three seconds, because a thread cannot be stopped from outside and a pattern with a back reference backtracks for
hours.
Zip files
The workspace holds three kinds of file: text (UTF-8, no NUL), images (ImageFiles) and zip files (ArchiveFiles.isZip: the PK signature). A zip file is
written by VirtualFileSystem.writeArchive (the upload of the workspace browser does it; the model has no tool that writes one) up to
WorkspaceLimits.maxArchiveBytes (10 MB by default, or the file limit when that is more), counts like any file, and cannot be read as text
(readText says to use unzip_file). maxAnyFileBytes() is the most any file may be, which is what WorkspaceMount.read allows.
unzip_file(path, target) is VirtualFileSystem.unzip -> WorkspaceMount.unzip, under the lock of the workspace, all or nothing: the archive is opened with
java.util.zip.ZipFile (so its central directory is the source of the sizes), ZipExtractor unpacks into a temporary folder next to the archive
(.marginalia-tmp- prefix, so no folder is made before the archive is known to be good), and the folder is moved to the target, which must not exist.
ZipExtractor trusts nothing of the archive:
-
Names: an entry with
..or a leading/refuses the whole archive (zip slip). Other names lose their.and empty parts, are normalized (NFC) and must passVirtualPath(an entry that does not is left out and reported; a\is a forbidden character, never a separator); the real path is built from the checked names below the temporary folder. A name starting with.marginalia-tmp-is left out. Two names equal in lower case, and a name that is both a file and a folder, refuse the archive. -
Only regular files and folders are made; a link in the archive is an ordinary entry with the target as its bytes, judged as any file.
-
Content: text, images (
ImageFiles.check) and zip files are kept within their limits; the rest is left out and named in the answer (the first 20). A zip file inside is stored, never unpacked, so there is no recursion. -
Bombs: at most 5 000 entries; the bytes inflated by all the entries (the ones left out too) must not pass
maxTotalBytes; an entry is read to the size it declares and refused when it inflates to more; the ratio of inflated to packed bytes is limited to 100 for an entry and for the archive (not looked at below 256 KiB); the files and bytes must fit what the workspace has room for. -
Encrypted and damaged archives give an error without details of the disk.
ZipUnpackTest covers it, with the archives made in the test.
Bringing the user in
Agents work while the user is away, so nothing here depends on an open browser.
-
Attention. Every status change writes
agent_runs.attentionin the same SQL update as the status (AgentRunStatus.needsAttention:IDLE,WAITING_APPROVAL,DONE,FAILED, aPAUSEDwith a reason of its own), so it is stored while the user is away;acknowledge(run)clears it. After the transaction commits,AgentAttentionService.publishcalls the listeners of the owner, an observer in memory with no UI in it. The Agents tab of every open UI subscribes increate()and runs the notice throughui.access+InRequestScope: the number on the tab, a notice with Open. Opening the console acknowledges the run. -
Activity. The console of an agent does not poll:
AgentActivityService.changed(agentId)says "look again" (nothing travels with it) andsubscribe(agentId, listener)hears it. It is fired after the commit byAgentRunServiceImpl.publishAfterCommit(every status change), byAgentMessageServiceImpl(every write to the transcript, the sub-agents' too), and byAgentRunnerImplfor the in-memory state no commit announces: the queue of the author (storeQueue) and the answer being written (LiveAnswerBuffer'sonChange, called onbegin, every chunk andend).AgentActivityServiceImplcoalesces: the first change of a burst schedules one deliveryCOALESCE_MILLIS(150 ms) later, on its one thread (agent-activity, so the order holds and the listener never runs inside a commit or on the thread of the model), so a streamed answer is a handful of refreshes a second at most. The listener is a closure over the UI; a detached UI isUIDetachedException, dropped, andonDetachunsubscribes. -
Webhook.
AgentAttentionServiceImpl.publishalso hands the notice toAgentWebhookService.notify(notice, screenOpen)(screenOpen: some UI of the user is subscribed). On its own thread (agent-webhook, the publishing thread is inside the commit of the change) it checks that the administrator allows it (AppSettings.agentWebhooksDisabled, null is allowed;setAllowedis admin only), that the change needs the user (Notice.attention), that the user may get messages (mayNotify: an administrator, or the permissionNOTIFICATIONSgranted) and that the user hasUserSetting.agentWebhookUrl(check:http/httpswith a host, at most 2000 characters;check(user, url)adds the address rule: unlessmayUseLocalAddresses- an administrator, or the granted permission withnotificationsAllowLocal- the host must resolve and none of its addresses may be loopback, link-local, private, shared (100.64/10), unique local, multicast or unspecified (AgentWebhookServiceImpl.isLocal); a local address and an unresolvable name get the same answer so it tells nothing about internal names; the name is resolved again by the HTTP client, so DNS rebinding is not covered) and, with a screen open,agentWebhookAlways; the text is theMSG_AGENT_NOTICE_*of the user's language (Localization.getValue(Language, L), the default language where a bundle lacks it) and goes out asWebhookFormat.PLAIN(the body, the agent name as theTitleheader when it is printable ASCII),DISCORD({"content"}) orGOTIFY({"title","message","priority"}) withjava.net.http(no redirects, 5 s to connect, 10 s for the answer, the answer is not read). Nothing that holds the URL is logged or returned: the URL may hold a secret, and the call is a request of the server to an address a user typed.sendTestis the button of the settings. A failure is a line in the log. -
Approvals. For an agent in the
ASKmode a destructive command waits.CommandFunctionscalls theApprovalthe runner gave it (Worker.approve), which registers the request withAgentApprovalService, moves the run toWAITING_APPROVAL, and returns the future of the decision. The console shows the oldest pending command inAgentApprovalDialog; Approve / Deny complete the future, and the continuation runs on a thread of the run in its own context, never in the session of the user's browser.backToWorkmoves the run toRUNNINGwhen nothing else waits. Pausing, stopping, finishing or failing the run refuses every pending command (rejectAll). -
Questions.
ask_user, or a reply with no call, ends the round withIDLE; the question is the status reason. The console does not show the status reason (it holds only the last one): the calls offinishandask_userare rendered as bubbles of their own (specialCall) at their place in the transcript, their acknowledgement (RunToolsImpl.isAcknowledgement) hidden, so they stay when the run goes on.AgentRunner.send(run, text)appends the answer, movesIDLEtoRUNNINGand starts a worker.
The screens
All under ui.dialogs.agent except the tab. They are ordinary @Configurable classes created with new, so they use the services and never touch the
runner's internals.
The console shows the model's results cut at RESULT_LIMIT (20 000), the same as the model got. Texts for the screens are L keys like any other (English, Japanese and the Chinese plugin bundle).
Writing commands for agents
An agent calls the commands that the user ticked in the editor, so every command is also an agent tool. The rules for writing commands apply; for an agent these matter most:
-
Get the book from
context.getScope(), never from the UI; return the data (text, JSON) instead of showing it. An item that is passed in is found through the scope (resolveMessage,resolveLorebook,resolveEntry), which keeps the command inside the working copy. -
Mark the severity honestly.
NON_DESTRUCTIVEcommands are what the default selection offers;DESTRUCTIVEfollows the agent's mode (DENYhides them). A command that only returns text and leaves the storing to the caller (the lore generator commands do this: they return the text, the agent writes the entry with the commands that exist for that) is the safest tool to hand out. -
Describe it for a model.
llmDescriptionTextis read by the model,descriptionTextby the human; say when to use it, what the arguments mean, what the result looks like and what it costs. A required prompt argument that is vague gives vague results: say what a good one contains (the lore generator throws "Give the prompt: what the entry is about, ..." for an empty one). Parameters can carryallowed,defaultValueandoptional; they become theenum,defaultandrequiredof the schema. -
Order the forms as a ladder. The overloads are positional, the adapter picks the form with the fewest parameters that has every argument the model passed, so put the rarely used options last and make them
optional. -
Keep results short or paged. Over 20 000 characters the model gets a cut with a note; give the command a limit or a page argument. The whole result is still kept for
@resultandsave_command_result. -
Long work goes
async = true. The command then runs on the command service's worker pool with the request context of the run (ThreadCopyRequestAttributes). A command that calls a model (lore generator) uses the blockingInferenceService.infer; with one slot on the model server that is sequential and re-reads the prompt, which is why such a command is worth its cost only for a task the agent cannot do itself. The runner cancels theCommandRunof a call when the run is paused at once, stopped, thrown away or sent a forced message (see Cancelling a call below): the command seesCommandContext.isTerminated()and its thread is interrupted, so check the one between the steps of the work and honour the other (CancellationException,InterruptedException), and leave the book in a state the agent can read; a synchronous command is not stopped. -
Fail with a message for the model. The text of the exception is what the model reads as
Error: <message>; no class names, say what to change.
Extensions bring commands the same way for the command bar and for agents: register a suite in onExtensionLoad, unregister it in onExtensionUnload.
lore-generator (list-lore-templates, generate-lore-entry, compact-lore-entry), name-generator (list-name-categories, generate-names)
name-provider (list-real-name-lists, pick-real-names), arc-outline (the outline and its generators) and character-tracker (characters and relationships) are examples; the example plugin shows the registration.
Logging
At INFO, always with the uuid of the run: a run started and taken up, the model and the limits when a worker begins, every round (tokens, the context against
its cap, the names of the calls), every call (name, time, length of the result, done or failed), every status change, a message of the user (only its length), an approval
asked and decided. The arguments and results of calls, messages and reports are the story's business and are never logged.
Tests
AgentTestBase (and the helper AgenticRequestAttributes) set up a user with the permission, a book and an AI; MockLLMServer plays the
model, so a whole run is tested without a network. The main classes: AgentRunnerTest (the loop with a scripted model), AgentRunServiceTest, AgentRunControlTest
(pause, stop, resume, the worker registry), AgentRunStatusTest (the table of changes), AgentWorkingCopyTest, ManuscriptLockTest, AgenticBooksTest,
AgentApprovalTest, AgentAttentionTest, CommandFunctionsTest and CommandFunctionsAgentTest (the adapter: forms, references, results, policy), AgentFileToolsTest,
VirtualFileSystemTest / VirtualPathTest (the cage of the files), AgentTranscriptExportTest, and the UI tests (AgentPartTest, AgentConsoleDialogTest,
AgentDialogTest, AgentWorkspaceBrowserTest...). An async command is only tested with the real AgentRequestAttributes (see
Extending
Not done: a read only window of the working copy, the sync of the changes of the book into the copy on resume, summaries of older rounds when the context is full (the run pauses instead).