artifactr.core¶
Pure rules for collaborating on artifacts: no I/O, no async (ADR-0001).
Hosts call needs to learn what to load, then commit or record to decide
a change, and persist the returned CommitResult. change_notes and
resume are pure projections over the log.
Artifacts¶
Artifact types, stored versions and the type registry. See Defining artifact types.
Artifact
pydantic-model
¶
Bases: BaseModel
Base class for artifact types.
Subclass it with ordinary Pydantic fields. Override render_for_agent to control
what the agent sees, and describe_change to summarize changes in your own words.
Config:
extra:forbidvalidate_assignment:True
kind
class-attribute
¶
kind: str
The registered type name, derived from the class name unless given as name=.
write_policy
class-attribute
¶
write_policy: WritePolicy = 'direct'
Whether agents change this type directly or through proposals.
render_for_agent
¶
render_for_agent() -> str
Return the text the agent sees for this artifact. Defaults to indented JSON.
describe_change
¶
Summarize the change from before to this state, or return None.
before is always an instance of the same type. Annotate it as Self when you
override this method; the base annotation is Any only so that overrides type-check.
When this returns None, the summary is generated from the patch.
MarkdownArtifact
pydantic-model
¶
Versioned
pydantic-model
¶
Bases: BaseModel
A stored artifact: its data plus identity, version and last author.
Config:
frozen:True
Fields:
-
id(ArtifactId) -
version(int) -
data(SerializeAsAny[A]) -
updated_by(Actor) -
archived(bool)
kind
property
¶
kind: str
The artifact's registered type name. Serialized, so readers can tell types apart.
edit
¶
edit(
change: Callable[[A], object],
*,
summary: str | None = None,
thread_id: ThreadId | None = None,
) -> EditArtifact
Build an edit command by mutating a copy of the data.
change receives a deep copy of the data and mutates it in place; its return value
is ignored. The difference becomes a JSON Patch based on this version.
edit_text
¶
edit_text(
old: str,
new: str,
*,
field: str = "text",
summary: str | None = None,
thread_id: ThreadId | None = None,
) -> EditArtifact
Build an edit command that replaces the unique occurrence of old with new.
archive
¶
archive(
*, thread_id: ThreadId | None = None
) -> ArchiveArtifact
Build a command that archives this artifact at this version.
WritePolicy
module-attribute
¶
WritePolicy = Literal['direct', 'propose']
How an artifact type accepts agents' changes: applied directly, or as proposals.
create_artifact
¶
create_artifact(
artifact: Artifact,
*,
artifact_id: ArtifactId | None = None,
thread_id: ThreadId | None = None,
) -> CreateArtifact
Build a command that creates artifact.
artifact_types
¶
Return a read-only view of every registered artifact type, by name.
get_artifact_type
¶
Return the artifact type registered under kind.
Raises:
| Type | Description |
|---|---|
NotFound
|
If no type is registered under that name. |
load_artifact
¶
Validate JSON data as an instance of the artifact type registered under kind.
Raises:
| Type | Description |
|---|---|
NotFound
|
If no type is registered under that name. |
ValidationFailed
|
If the data does not validate. |
load_versioned
¶
load_versioned(
*,
id: ArtifactId,
kind: str,
version: int,
data: Mapping[str, Any],
updated_by: Actor,
archived: bool = False,
) -> Versioned[Artifact]
Rebuild a stored artifact from its parts, validating the data against its type.
Actors¶
Who did something. See Opening a workspace.
Actor
module-attribute
¶
Actor = Annotated[
UserActor
| AgentActor
| ExternalAgentActor
| SystemActor,
Field(discriminator="kind"),
]
Any actor, discriminated by kind.
UserActor
pydantic-model
¶
Bases: BaseModel
A person, identified by the host application's user id.
Config:
frozen:True
Fields:
AgentActor
pydantic-model
¶
Bases: BaseModel
The built-in agent of one thread, acting within one run.
Config:
frozen:True
Fields:
ExternalAgentActor
pydantic-model
¶
SystemActor
pydantic-model
¶
is_agent
¶
Return whether the actor is an agent, built-in or external.
Agents are subject to artifact write policies; people and the system are not.
same_participant
¶
Return whether two actors are the same participant.
Commands¶
Intents to change a workspace. See Commands and outcomes.
Command
module-attribute
¶
Command = Annotated[
CreateArtifact
| EditArtifact
| ArchiveArtifact
| ProposeChange
| RespondToProposal
| CreateThread
| PostMessage
| SetFocus
| SetThreadMode
| AnswerDeferred,
Field(discriminator="type"),
]
Any command, discriminated by type.
CreateArtifact
pydantic-model
¶
Bases: _Command
Create an artifact.
If the artifact type's write policy (or the thread's mode) requires an agent to propose,
the command is recorded as a proposal with proposal_id instead.
Fields:
-
type(Literal['create_artifact']) -
artifact_id(ArtifactId) -
kind(str) -
data(dict[str, JsonValue]) -
thread_id(ThreadId | None) -
proposal_id(ProposalId)
EditArtifact
pydantic-model
¶
Bases: _Command
Apply a patch to an artifact, based on a specific version.
If the artifact type's write policy (or the thread's mode) requires an agent to propose,
the command is recorded as a proposal with proposal_id instead.
Fields:
-
type(Literal['edit_artifact']) -
artifact_id(ArtifactId) -
base_version(int) -
patch(Patch) -
summary(str | None) -
thread_id(ThreadId | None) -
proposal_id(ProposalId)
ArchiveArtifact
pydantic-model
¶
Bases: _Command
Archive an artifact. Archived artifacts can be read but not changed.
Fields:
-
type(Literal['archive_artifact']) -
artifact_id(ArtifactId) -
base_version(int) -
thread_id(ThreadId | None) -
proposal_id(ProposalId)
ProposeChange
pydantic-model
¶
Bases: _Command
Propose a change for someone else to accept, whatever the write policy.
Fields:
-
type(Literal['propose_change']) -
proposal_id(ProposalId) -
change(ProposedChange) -
rationale(str | None)
ProposedChange
module-attribute
¶
ProposedChange = Annotated[
CreateArtifact | EditArtifact | ArchiveArtifact,
Field(discriminator="type"),
]
A change that a proposal would make when accepted.
RespondToProposal
pydantic-model
¶
Bases: _Command
Accept or reject a pending proposal.
changes is an optional patch applied on top of the proposed result when accepting, so a
person can accept a proposal with their own edits.
Fields:
-
type(Literal['respond_to_proposal']) -
proposal_id(ProposalId) -
decision(Literal['accept', 'reject']) -
changes(Patch | None) -
reason(str | None)
CreateThread
pydantic-model
¶
PostMessage
pydantic-model
¶
SetFocus
pydantic-model
¶
Bases: _Command
Set the artifacts a thread is focused on.
Fields:
-
type(Literal['set_focus']) -
thread_id(ThreadId) -
artifact_ids(tuple[ArtifactId, ...])
SetThreadMode
pydantic-model
¶
Bases: _Command
Switch a thread between edit and suggest mode.
Fields:
-
type(Literal['set_thread_mode']) -
thread_id(ThreadId) -
mode(ThreadMode)
ThreadMode
module-attribute
¶
ThreadMode = Literal['edit', 'suggest']
How agents change artifacts in a thread: directly, or always through proposals.
AnswerDeferred
pydantic-model
¶
Outcomes¶
What a command did.
Outcome
module-attribute
¶
What a command did, discriminated by type.
Applied
pydantic-model
¶
Bases: _Outcome
The change was applied: the artifact is now at version.
Fields:
-
seq(int | None) -
type(Literal['applied']) -
artifact_id(ArtifactId) -
version(int)
Proposed
pydantic-model
¶
Bases: _Outcome
The change was recorded as a proposal instead of being applied.
Fields:
-
seq(int | None) -
type(Literal['proposed']) -
proposal_id(ProposalId)
Resolved
pydantic-model
¶
Bases: _Outcome
A proposal was accepted (the artifact is now at version) or rejected.
Fields:
-
seq(int | None) -
type(Literal['resolved']) -
proposal_id(ProposalId) -
decision(Literal['accept', 'reject']) -
version(int | None)
Recorded
pydantic-model
¶
Rejections¶
The ways a command can fail. See Rejections.
VersionConflict
¶
ValidationFailed
¶
NotFound
¶
Patches¶
The two ways artifact data changes. See How changes are expressed.
Patch
module-attribute
¶
Any patch, discriminated by kind.
JsonPatch
pydantic-model
¶
TextEdits
pydantic-model
¶
Bases: BaseModel
A sequence of anchored replacements in one text field.
Each old must occur exactly once in the field at the moment it is applied. An empty
old is allowed only when the field is empty, to write its first content.
Config:
frozen:True
Fields:
TextEdit
pydantic-model
¶
apply_patch
¶
Apply a patch to JSON data, returning new data.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, JsonValue]
|
The artifact's current JSON data. It is not modified. |
required |
patch
|
Patch
|
The patch to apply. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, JsonValue]
|
The patched data. |
Raises:
| Type | Description |
|---|---|
PatchFailed
|
If the patch does not apply. |
diff
¶
Return the JSON Patch that turns old into new.
describe_patch
¶
Return a short, human-readable description of a patch.
Used as the change summary when neither the command nor the artifact type provides one.
Entities¶
Threads, proposals, revisions and runs, as stored.
Thread
pydantic-model
¶
Bases: BaseModel
A chat, with its mode and the artifacts it is focused on.
Config:
frozen:True
Fields:
-
id(ThreadId) -
title(str) -
mode(ThreadMode) -
focus(tuple[ArtifactId, ...])
Proposal
pydantic-model
¶
Bases: BaseModel
A change proposed by one participant for another to accept or reject.
Config:
frozen:True
Fields:
-
id(ProposalId) -
change(ProposedChange) -
proposed_by(Actor) -
rationale(str | None) -
status(Literal['pending', 'accepted', 'rejected']) -
thread_id(ThreadId | None)
artifact_id
property
¶
artifact_id: ArtifactId
The artifact the proposal creates, changes or archives.
Revision
pydantic-model
¶
Bases: BaseModel
One immutable version of an artifact (ADR-0004).
Config:
frozen:True
Fields:
-
artifact_id(ArtifactId) -
version(int) -
kind(str) -
data(dict[str, JsonValue]) -
patch(Patch | None) -
actor(Actor) -
proposal_id(ProposalId | None) -
archived(bool)
Run
pydantic-model
¶
Bases: BaseModel
An agent run, which may pause for deferred requests and resume.
Config:
frozen:True
Fields:
-
id(RunId) -
thread_id(ThreadId) -
status(RunStatus) -
pending(tuple[DeferredRequest, ...]) -
answers(dict[str, DeferredAnswer])
RunStatus
module-attribute
¶
RunStatus = Literal[
"running", "paused", "completed", "stopped", "failed"
]
DeferredAnswer
pydantic-model
¶
Events¶
Facts on a workspace's log, and the envelope each travels in. See The log.
Envelope
pydantic-model
¶
Bases: BaseModel
A stored event with its position and attribution. Its shape is also the wire shape.
Fields:
Event
module-attribute
¶
Event = Annotated[
Annotated[ThreadCreated, Tag("thread_created")]
| Annotated[
ThreadModeChanged, Tag("thread_mode_changed")
]
| Annotated[FocusChanged, Tag("focus_changed")]
| Annotated[MessagePosted, Tag("message_posted")]
| Annotated[ArtifactCreated, Tag("artifact_created")]
| Annotated[ArtifactChanged, Tag("artifact_changed")]
| Annotated[ArtifactArchived, Tag("artifact_archived")]
| Annotated[ProposalCreated, Tag("proposal_created")]
| Annotated[ProposalResolved, Tag("proposal_resolved")]
| Annotated[RunStarted, Tag("run_started")]
| Annotated[ToolCalled, Tag("tool_called")]
| Annotated[ToolReturned, Tag("tool_returned")]
| Annotated[RunPaused, Tag("run_paused")]
| Annotated[DeferredAnswered, Tag("deferred_answered")]
| Annotated[RunEnded, Tag("run_ended")]
| Annotated[AppEvent, Tag("app_event")]
| Annotated[UnknownEvent, Tag("unknown")],
Discriminator(_event_tag),
]
Any event, discriminated by type; unknown types validate as UnknownEvent.
KnownEvent
module-attribute
¶
KnownEvent = (
ThreadCreated
| ThreadModeChanged
| FocusChanged
| MessagePosted
| ArtifactCreated
| ArtifactChanged
| ArtifactArchived
| ProposalCreated
| ProposalResolved
| RunStarted
| ToolCalled
| ToolReturned
| RunPaused
| DeferredAnswered
| RunEnded
| AppEvent
)
Every event type this version defines.
UnknownEvent
pydantic-model
¶
ThreadCreated
pydantic-model
¶
ThreadModeChanged
pydantic-model
¶
Bases: _Event
A thread switched between edit and suggest mode.
Fields:
-
type(Literal['thread_mode_changed']) -
thread_id(ThreadId) -
mode(ThreadMode)
FocusChanged
pydantic-model
¶
Bases: _Event
The set of artifacts a thread is focused on changed.
Fields:
-
type(Literal['focus_changed']) -
thread_id(ThreadId) -
artifact_ids(tuple[ArtifactId, ...])
MessagePosted
pydantic-model
¶
ArtifactCreated
pydantic-model
¶
Bases: _Event
An artifact was created at version 1.
Fields:
-
type(Literal['artifact_created']) -
thread_id(ThreadId | None) -
run_id(RunId | None) -
artifact_id(ArtifactId) -
kind(str) -
version(int) -
data(dict[str, JsonValue]) -
proposal_id(ProposalId | None)
ArtifactChanged
pydantic-model
¶
Bases: _Event
An artifact was changed by a patch.
Fields:
-
type(Literal['artifact_changed']) -
thread_id(ThreadId | None) -
run_id(RunId | None) -
artifact_id(ArtifactId) -
kind(str) -
version(int) -
patch(Patch) -
summary(str) -
proposal_id(ProposalId | None)
ArtifactArchived
pydantic-model
¶
Bases: _Event
An artifact was archived.
Fields:
-
type(Literal['artifact_archived']) -
thread_id(ThreadId | None) -
run_id(RunId | None) -
artifact_id(ArtifactId) -
kind(str) -
version(int) -
proposal_id(ProposalId | None)
ProposalCreated
pydantic-model
¶
Bases: _Event
A change was proposed.
Fields:
-
type(Literal['proposal_created']) -
thread_id(ThreadId | None) -
run_id(RunId | None) -
proposal_id(ProposalId) -
change(ProposedChange) -
rationale(str | None)
ProposalResolved
pydantic-model
¶
Bases: _Event
A proposal was accepted or rejected.
Fields:
-
type(Literal['proposal_resolved']) -
thread_id(ThreadId | None) -
run_id(RunId | None) -
proposal_id(ProposalId) -
decision(Literal['accept', 'reject']) -
proposed_by(Actor) -
artifact_id(ArtifactId) -
changes(Patch | None) -
reason(str | None) -
version(int | None)
RunEvent
module-attribute
¶
RunEvent = (
RunStarted
| ToolCalled
| ToolReturned
| RunPaused
| RunEnded
)
Facts about agent runs, recorded by the agent layer rather than commanded.
RunStarted
pydantic-model
¶
ToolCalled
pydantic-model
¶
ToolReturned
pydantic-model
¶
RunPaused
pydantic-model
¶
Bases: _Event
A run paused until the listed requests are answered.
Fields:
-
type(Literal['run_paused']) -
run_id(RunId) -
thread_id(ThreadId) -
requests(tuple[DeferredRequest, ...])
DeferredRequest
pydantic-model
¶
DeferredAnswered
pydantic-model
¶
RunEnded
pydantic-model
¶
RunUsage
pydantic-model
¶
AppEvent
pydantic-model
¶
scope_of
¶
scope_of(
event: KnownEvent,
) -> tuple[ThreadId | None, RunId | None]
Return the thread and run an event belongs to, for its envelope.
delivered_to
¶
delivered_to(
envelope: Envelope, threads: Collection[ThreadId] | None
) -> bool
Return whether a subscriber following threads receives envelope.
Workspace-scoped events (artifacts and proposals) reach every subscriber, whichever thread
they originated in. Thread-scoped events reach subscribers that follow their thread.
None follows every thread.
Rules¶
The host contract: what to load, and how a command or a fact is decided (ADR-0018). Workspace.commit and Workspace.record use these; call them directly only when you write a host of your own.
needs
¶
commit
¶
commit(
command: Command, state: State, *, actor: Actor
) -> CommitResult
Decide a command against the loaded state.
Returns:
| Type | Description |
|---|---|
CommitResult
|
What to persist, and the outcome to report. |
Raises:
| Type | Description |
|---|---|
Rejection
|
If the command cannot be applied; see |
NotLoaded
|
If |
record
¶
record(
fact: Fact, state: State, *, actor: Actor
) -> CommitResult
Decide a fact about an agent run, or an application event, against the loaded state.
Only the thread's own agent, or the system, may record facts about the thread's runs.
Raises:
| Type | Description |
|---|---|
Rejection
|
If the fact contradicts the run's state, such as a tool call after the run ended, or the actor may not record it. |
NotLoaded
|
If |
Fact
module-attribute
¶
A fact recorded by the agent layer rather than commanded.
NotLoaded
¶
Bases: LookupError
The host called a rule without loading an entity that needs asked for.
This is a bug in the host, not a rejection of the command.
State
dataclass
¶
State(
artifacts: Mapping[
ArtifactId, Versioned[Artifact] | None
] = _EMPTY,
proposals: Mapping[
ProposalId, Proposal | None
] = _EMPTY,
threads: Mapping[ThreadId, Thread | None] = _EMPTY,
runs: Mapping[RunId, Run | None] = _EMPTY,
)
The slice of a workspace that core needs to decide one command.
Each mapping holds what the host loaded, keyed by id. A key whose value is None was
looked up and does not exist; a missing key was not loaded (see needs).
Needs
dataclass
¶
Needs(
artifacts: frozenset[ArtifactId] = frozenset(),
proposals: frozenset[ProposalId] = frozenset(),
threads: frozenset[ThreadId] = frozenset(),
runs: frozenset[RunId] = frozenset(),
)
Ids a host must load into State before core can decide a command.
CommitResult
dataclass
¶
CommitResult(
outcome: Applied | Proposed | Resolved | Recorded,
events: tuple[KnownEvent, ...] = (),
artifacts: tuple[Versioned[Artifact], ...] = (),
revisions: tuple[Revision, ...] = (),
proposals: tuple[Proposal, ...] = (),
threads: tuple[Thread, ...] = (),
runs: tuple[Run, ...] = (),
)
Everything a host persists after a command, in one transaction.
artifacts, proposals, threads and runs are entities to insert or replace;
revisions are appended; events are appended to the log in order.
Change notes¶
What others did, told to one viewer. See Change notes.
change_notes
¶
change_notes(
envelopes: Iterable[Envelope],
*,
viewer: Actor,
focus: Collection[ArtifactId] | None = None,
) -> list[Note]
Return notes about what others did, in the order it happened.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
envelopes
|
Iterable[Envelope]
|
A slice of the log, in |
required |
viewer
|
Actor
|
Who the notes are for; their own actions are left out. |
required |
focus
|
Collection[ArtifactId] | None
|
The artifacts the viewer follows. |
None
|
Returns:
| Type | Description |
|---|---|
list[Note]
|
One |
list[Note]
|
relevant proposal event. |
render_notes
¶
Render notes as a bulleted list, one line each.
ChangeNote
pydantic-model
¶
ProposalNote
pydantic-model
¶
Bases: BaseModel
A proposal made by someone else, or a decision on the viewer's own proposal.
Config:
frozen:True
Fields:
-
type(Literal['proposal']) -
proposal_id(ProposalId) -
artifact_id(ArtifactId) -
actor(str) -
action(Literal['proposed', 'accepted', 'rejected']) -
detail(str | None) -
version(int | None)
Live events¶
A run's token-level output. See Live output.
LiveFrame
pydantic-model
¶
LiveEvent
module-attribute
¶
LiveEvent = Annotated[
PartStarted
| TextDelta
| ThinkingDelta
| ToolArgsDelta
| PartEnded
| Draft
| AppLive,
Field(discriminator="type"),
]
Any live event, discriminated by type.
PartStarted
pydantic-model
¶
TextDelta
pydantic-model
¶
ThinkingDelta
pydantic-model
¶
ToolArgsDelta
pydantic-model
¶
PartEnded
pydantic-model
¶
Draft
pydantic-model
¶
Bases: _LiveEvent
A full snapshot of an artifact being generated, not yet committed.
Fields:
-
type(Literal['draft']) -
kind(str) -
data(dict[str, JsonValue]) -
artifact_id(ArtifactId | None)
AppLive
pydantic-model
¶
Protocol frames¶
The thread protocol's frames and its resume rule. See the thread protocol.
PROTOCOL
module-attribute
¶
PROTOCOL: Final = 'artifactr.v1'
The protocol version this library speaks.
Hello
pydantic-model
¶
Welcome
pydantic-model
¶
ActiveRun
pydantic-model
¶
EventFrame
pydantic-model
¶
ReplayComplete
pydantic-model
¶
CommandFrame
pydantic-model
¶
Bases: BaseModel
A client's command, with an id that correlates it with its result.
command_id is also an idempotency key: the server deduplicates repeated ids.
Config:
frozen:True
Fields:
-
type(Literal['command']) -
command_id(str) -
command(FrameCommand)
FrameCommand
module-attribute
¶
Anything a client can ask for in a command frame.
StopRun
pydantic-model
¶
WatchRun
pydantic-model
¶
CommandResult
pydantic-model
¶
ErrorFrame
pydantic-model
¶
ClientFrame
module-attribute
¶
ClientFrame = Annotated[
Hello | CommandFrame, Field(discriminator="type")
]
Any frame a client sends.
ServerFrame
module-attribute
¶
ServerFrame = Annotated[
Welcome
| EventFrame
| ReplayComplete
| LiveFrame
| CommandResult
| ErrorFrame,
Field(discriminator="type"),
]
Any frame the server sends.
resume
¶
resume(
hello: Hello,
*,
head_seq: int,
first_retained_seq: int = 1,
) -> ResumePlan
Decide where replay starts for a connecting client.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
hello
|
Hello
|
The client's hello frame. |
required |
head_seq
|
int
|
The workspace log's latest |
required |
first_retained_seq
|
int
|
The oldest |
1
|
Raises:
| Type | Description |
|---|---|
UnsupportedProtocol
|
If the client speaks another protocol version. |
ResumePlan
pydantic-model
¶
Bases: BaseModel
Where a connection's replay starts.
Config:
frozen:True
Fields:
-
replay_after(int) -
reset(bool)
Identifiers¶
Identifiers are plain strings. These aliases say what a string identifies, and the factories generate ids with a short type prefix.