View, Repository: Conversation and Record, two declarations for showing
records as chat threads. A repository's conversation() (ConversationProtocol)
declares, beside its data view, how its records read as messages: a channel that
repositories naming the same one merge into, and the fields for thread, time,
text, and optionally direction and sender. With no direction, its records are
cards rather than messages. Annotated[int, Record(Drafts)] marks a function argument
as one of a repository's records, by field (default "id"), so a consumer can show
the function as an action on each record, such as a card's Send button. Both are
declarations only: nothing here looks a record up or checks a field, and the Context
never injects a Record argument.
Logger: a record written while a protocol call is in progress — a logger.info()
inside execute(), say — carries that call's id as parent, so every log line says
which call wrote it. A line written by a function belongs to the function's call, not
its caller's. Records written outside any call (in __init__, during resolution) have
no parent. The text format renders it as [parent=<id>].
Context: context.trace() nodes gain logs, the component log lines written inside
each call (ts, level, message, fields).
Context: context.logs(call_id) (and LogManager.for_call()) returns every record
written by or during one call, across files, so a logs view can filter one run even
when runs of the same component overlap.
Logger: log rotation, under [project.logging]: max-bytes (off by default) and
backups (default 5). A file that a record would grow past max-bytes rotates to
<key>.1.jsonl (.log in the text format), shifting older backups. tail() now reads
backwards from the end, and into backups only if needed, so its cost follows the lines
asked for rather than the file's size. records() reads each component's backups oldest
first, then its current file, and takes since= to skip files last written before a
time; context.trace(call_id, since=...) passes it through.
Notifications: Notification.id, unique per publish and logged as the record's
notification, so a notification can be deduplicated, acknowledged, or followed.
Events: Event.cause. A top-level call made by a notification listener records the
notification's id as its cause (also on its log records), and keeps
parent=None. Calls nested inside it have a parent instead. In context.trace(),
each notification gains id and triggered, the subtrees of the calls it caused, so a
trace follows a reaction from the publishing call to what its listeners did, across
files. context.trace(call_id, root=True) (and build_trace(..., root=True)) climbs
first, through parent links and across causes, and returns the whole story from its
top; it never climbs into a Service's run() or a Repository's stream().
Notifications: @Subscriber(*topics, deliver="sync") declares that a component
reacts to notifications, through its on_notification(notification)
(SubscriberProtocol). The Context subscribes it right after the component starts
and unsubscribes it right before it stops (including context.stop(key) on a service),
and each delivery is an instrumented SubscriberProtocol call: it emits Events, is
logged, records the notification as its cause, and a failure is its failed record,
never the publisher's problem. context.subscribers() lists them. Goes on top of the
execution type, like a layer: it is a namespace too, so Subscriber.items() lists every
subscriber and Subscriber.of(cls) returns its Subscription.
Notifications: @Subscriber(..., deliver="background") runs a slow reaction off the
publisher's thread: each background subscriber gets its own worker thread and bounded
queue, keeping publish order. A full queue blocks publish() (one WARNING per
episode); stopping the component drains its queue first, up to a timeout, and logs any
dropped deliveries. Set under [project.notifications]: queue-size (default 1000)
and drain-timeout (seconds, default 10).
Notifications: @Publisher(*topics) declares the topics a component or @Function
publishes; context.publishers() lists them, so a UI can draw who publishes what before
anything has been published. A namespace like Subscriber: Publisher.items(),
Publisher.of(target).
Context: context.notification_history(topic=, key=, level=, summary_only=,
before=, since=, limit=) returns the latest notifications from every publisher,
newest first, paged with each row's opaque cursor. Every notification is also written
to <log-dir>/history/notifications.jsonl, which holds nothing else, so reading it
never scans the component logs. The history starts at the upgrade: notifications
logged before stay in their components' logs only. Size it with history-max-bytes
and history-backups under [project.logging] (defaulting to max-bytes and
backups). normalize_key() is exported for comparing publisher keys the same way.
See Notifications.
Notifications: a notification's log record carries its summary, and so do a
trace's notifications (recovered from message for records logged before).
Logger: LogsUnavailable, a ValueError, raised by trace(), logs() and
notification_history() when records can't be read back (file logging off, or the
text format for trace() and logs()), so a caller can tell it from a bad argument.
Docs: One Call per Inbound Item — route
each item a long-running service receives through an injected @Function, so it gets
its own call id, log lines, notifications and trace instead of joining run()'s.
Docs: the site wears the odf UI's ink theme, the same as the odf docs: black
and white with grey hairlines and rounded panels, and its stacked-layers tile as the
logo and favicon, in a light and a dark variant.
Examples: examples/11-notifications/ uses @Publisher and @Subscriber (one
sync, one background) and prints the trace from the publishing call to every reaction.
Notifications: publishing through an injected notifications: Notifications now
follows three rules: the component or function must be a @Publisher
(else TypeError), the topic must be one it declares (else ValueError), and a
protocol call must be in progress, never __init__ or a thread of your own
(else RuntimeError), so every notification has a parent and can be traced. Add
@Publisher("your.topic", ...) on top of each publisher's execution type. The unbound
context.notifications is unaffected.
Notifications: a subscribe() listener that published through its injected view
now raises, since a listener runs outside any call. Turn it into a @Subscriber that is
also a @Publisher, or publish through context.notifications.
Notifications: a payload is now read-only all the way down, not just at the top
level. Nested mappings arrive as read-only mappings, lists and tuples as tuples, and
sets as frozensets, all rebuilt so nothing is shared with the publisher or between
subscribers. Other values are passed as they are. Code that mutated a nested value, or
expected a nested list, needs updating.
Events: build_trace() (and so context.trace()) no longer recurses forever on
cyclic parent links in a hand-written or corrupt log; the branch ends at the repeat.
Notifications: an unbound publish(key=...) whose key contains / or \, starts
with ., is empty, or is too long for a file name now raises ValueError, before
anything is logged or delivered, instead of writing a file outside the log directory
(or failing with an OSError).
Notifications: components can now publish a Notification about something that
happened (a file arrived, a threshold was crossed, an order came in), so others can
react without the publisher knowing who they are. A notification has a dotted topic,
a read-only payload, an optional summary for a UI to show, a level ("info",
"success", "warning", or "error"), and an optional subject and record naming
what it is about. It also records the protocol call in progress as its parent. A
component or function declares notifications: Notifications and receives a view
bound to its own key, the way it declares logger: Logger, so
notifications.publish("orders.received", {...}) needs no key. Code holding the
Context uses context.notifications. subscribe(listener, topic=...) takes an exact
topic or a "orders.*" prefix and returns an unsubscribe function. Delivery is
synchronous, listeners run with no current call, and a listener's exception is logged
and never reaches the publisher. Each publish is logged as
notified <topic>: <summary>, at the level matching the notification's own.
Notifications are separate from Events: the framework never publishes one, and
neither hub delivers the other's type. New example: examples/11-notifications/. See
Notifications.
Logger: logs are now structured. Each line is a record, and by default a file is
JSON Lines, logs/<namespace>.<name>.jsonl, one JSON object per line. Every record has
ts (ISO 8601 with milliseconds), level, key, namespace, name, source and
message. Records Events writes add the event's call, parent, protocol, method,
state, elapsed_ms and error, so a pipeline's steps, which log to their own files,
can be traced back to the pipeline by parent. Notification records add topic,
notification_level, subject, record and parent. Logger calls take keyword
arguments as structured fields: logger.info("order taken", order_id=7) writes
"fields": {"order_id": 7}. tail_logs() returns the full records. See
Logger.
Logger: [project.logging] format = "text" writes readable .log files instead,
one line per record, with the call and parent ids in full ([call=<id> parent=-])
and fields appended as name=value. opendataframework.logger.render(record) turns
a JSON record into that line. See Logger.
Logger: log levels can now be set in config, under [project.logging]: a default
level, one for events (protocol-call records) and one for notifications, and one
per namespace ([project.logging.task] level = ...) or per component
([project.logging.task] take-orders = "DEBUG"). The most specific setting wins. Level
and format names are checked when the project is created, and namespace and component
names when it starts, so a typo raises instead of silently doing nothing. Context
takes the same mapping as log_config=. See Logger.
Context: context.trace(call_id) returns the tree of a protocol call and every call
made inside it, read from the logs: each node merges a call's started and
completed/failed records (key, protocol, method, state, started, ended,
elapsed_ms, error), lists the notifications published during it, and holds its
children in start order. It works after the call ended and after a restart, takes the
call's full id, and reads the .jsonl files of the configured json format only: with
format = "text" it raises ValueError, and leftover .log files are ignored.
opendataframework.events.build_trace(items, call_id) builds the same JSON-ready tree
from live Events or log records, so a UI can draw a call as it runs. See
Logger.
Context: Resolver.namespaces returns the namespaces a Resolver collects from.
Logger (breaking): log files are now JSON Lines (.jsonl) by default instead of
text (.log), and ts is ISO 8601 with milliseconds and the UTC offset. Set
[project.logging] format = "text" to keep readable text files. tail_logs() still
returns ts, level and message in every record, plus the other fields.
Logger (breaking): the default threshold is now INFO, so logger.debug() records
are dropped unless enabled, e.g. with [project.logging] level = "DEBUG".
Logger (breaking): protocol-call records are now written by Events, with one
wording for every call: <method>() started, <method>() completed in 1.2ms, and
<method>() failed in 1.2ms: <error> at ERROR (call ... for a @Function). They
replace setup() starting/setup() complete,
on_start() called/on_start() complete, stop() called, run() returned, and
... raised .... Code matching the old wording in tail_logs() output needs updating.
Events: the framework now reports every lifecycle and execution protocol call it
makes as an Event: a Service's setup()/run()/stop(), on_start()/on_stop(),
a Task's or Pipeline's execute(), an Agent's invoke(), a @Function call
made through the Context, and a repository's open_stream()/stream()/close_stream().
Each call emits started, then completed or failed, with its duration, its error,
and the parent call it happened inside, so a pipeline's steps and a task's function
calls can be traced. A component declares events: Events to receive the Context's
instance, the way it declares logger: Logger. Code holding the Context uses
context.events. events.subscribe(listener, protocol=...) returns an unsubscribe
function, and a listener's exception is logged and never affects the call. Events work
with file logging off, and log wording is unchanged. See Events.
Function: added FunctionProtocol, the interface of a registered function and the
protocol its calls are reported under. It is deliberately not runtime_checkable.
See Function.
View: TimeseriesView gains two optional fields. fields selects which numeric
fields to plot — None, the default, plots every numeric field other than field and
group, as before. group names a field whose distinct values each get their own line
(e.g. run_id), so runs can be compared on one chart. field may be any field records
are ordered by, not only a timestamp (e.g. a training step). Existing
TimeseriesView(field=...) declarations are unchanged. Rendering the new fields happens
on the odf side. See View.
Agent: added @Agent, an execution type for goal-driven work whose steps are decided
at run time — typically by a language model. An agent is a class with a single invoke()
method whose signature is its author's to choose; everything it may act on is declared
through its constructor like any other dependency, so it can also be injected into a
Pipeline, a Service, or another agent. context.call("agent.librarian", question=...)
invokes one by key, context.parameters(Librarian) returns the parameters of
its invoke(), and every invocation is logged like a Task's execute(). @Agent on a
class without invoke() raises TypeError at decoration time. AgentProtocol documents
the interface. The framework still ships no model client and no agent loop — invoke() is
where a library or provider SDK of your choice goes. See Agent.
Function: added @Function, a marker that makes a plain function known to the
framework the way @Component does a class — so an existing function-based codebase can
be adopted without a class refactor first. The function is returned unchanged, and direct
calls behave exactly as before. Through the Context, parameters annotated with a managed
type are injected and the rest are call arguments: context.call("function.load-data", path=...)
calls by key, context.get(load_data) returns the function with its
dependencies bound, context.functions lists what was resolved, and
context.parameters(func) returns the parameters a caller must supply. A class — or
another function — asks for one by naming it in its signature,
Annotated[Callable[..., int], load_data], and receives it with its dependencies bound
and its calls logged — and with a signature that leaves the injected parameters out, so
it can be handed to a library that builds a tool schema from a callable; the function
then takes part in dependency ordering and cycle detection. Functions have no lifecycle and aren't in context.instances. See
Function.
View: added ContentMetadata/ContentMetadataProtocol so a Repository whose view
points at content (ImageView/VideoView/AudioView/DocumentView) can declare, per
record, that content's filename and content_type via an optional
content_metadata(entity) method, detected structurally like DataViewProtocol/
ReplayProtocol. The values needn't be entity fields — a repository may derive them or
return constants. Existing views are unchanged. Using this to label, type and offer
content for download happens on the odf side. See View.
Layer: a function registered with @Function can now be tagged with a layer
(@Storage @Function def load_data(...)), like a class. See Layer.
Context: a required parameter that is not injected because its annotation is a
string (every annotation under from __future__ import annotations) now fails with a
TypeError that names the string annotation as the cause, for a constructor and for a
function called through the Context. String annotations are still not injected.
Namespace (breaking): each decorator now states what it may be applied to, and
raises TypeError at decoration time otherwise. Two cases were accepted before:
a class under two execution types (@Component @Task class X) — a class takes one —
and a layer on a class no execution type has registered (@Storage class X, or
@Component @Storage class X with the layer underneath). To migrate, keep a single
execution type on the class and put the layer above it. @Component and the other
execution types on a function still raise, and now point to @Function. A plain
Namespace subclass accepts a function as well as a class. See
Namespace.
Context (breaking): async is not supported, and now says so. An async def
function registered with @Function, or an async defexecute(), invoke(),
setup()/run()/stop(), on_start()/on_stop() or repository stream method, makes
project.start() raise TypeError naming it. These were previously called without
being awaited — a hook that silently never ran, or a call logged as complete before it
started. To migrate, make it synchronous, or keep it out of the Context and call it
yourself. See Context.
Component (breaking): an McpTool now requires a described, fully typed handler,
and raises when it is created otherwise — ValueError for an empty description,
TypeError for a handler with an unannotated parameter, *args/**kwargs, no return
annotation, or an annotation left as a string. A caller, typically a model, sees
nothing of a tool but its description and signature, and a handler without them was
accepted and offered with a schema that said little. To migrate, annotate the handler's
parameters and return value (-> None counts), replace a lambda with a method or a
function, and remove from __future__ import annotations from the handler's module.
See Component.
Context (breaking): everything addressed by a string is now addressed by a key,
<namespace>.<name> — execute("task.load-job"), call("agent.librarian", ...),
start("service.postgres"), start_stream("repository.webcam"),
tail_logs("component.cache") and the rest. The name is the registered one (kebab-case
class or function name, or the explicit name=), and keys are accepted in any casing.
A name is only unique within its namespace, and the bare names used before collided
across them: with a @Component class Loader and a @Task class Loader,
execute("Loader") could find the component and raise TypeError, a @Function and an
@Agent sharing a name could not be call()ed at all, and every same-named object
wrote to one log file. Now each is reachable, and log files are named
<namespace>.<name>.log (logs/task.loader.log). context.key(obj) returns the key
of a class or function you hold. A string without a namespace raises ValueError, and
the ValueErrorcall() raised for a function and an agent sharing a name is gone. To
migrate, prefix every name passed to the Context with its namespace, and point
anything reading log files at the new names. See
Context.
Namespace (breaking): registering one object under two names in the same
namespace (@Task("a") @Task("b") class X) now raises ValueError, so every object
has exactly one key. See Namespace.
Repository (breaking): @Repository(...) now requires its argument to be a class
registered with @Entity, and raises TypeError at decoration time otherwise. The docs
always described the argument as the Entity class, but any class was accepted — so
@Repository written directly above the repository class (entity left out) took that
class for its own entity, registered nothing, and left the repository silently missing
from the Context. To migrate, add @Entity to the entity class; for a type you don't
own, call Entity(SomeType) once or wrap it in a small entity class. See
Repository.
Context: a class that declared the same dependency under two constructor parameters
(def __init__(self, source: Db, target: Db)) failed at project.start() with a
misleading Circular dependency detected among: Sync. It still fails — the Context
holds a single Db, so both parameters would receive the same object — but now with a
ValueError that says so and names the class, the dependency and the parameters.
Config: key normalisation only applied to the name being looked up, not to the keys
in the data, so a key written as exclude_years (or ExcludeYears) in TOML couldn't be
found by any access path. config.exclude_years and config["exclude_years"] raised,
and config.get("exclude_years", default) silently returned the default. Config now
normalises its keys to kebab-case on construction (recursively through nested tables),
so snake_case/kebab-case/PascalCase are interchangeable on both sides. Two keys in the
same table that normalise to the same name (e.g. exclude_years and exclude-years)
now raise ValueError instead of one silently shadowing the other. See Config.
Namespace: applying a decorator (@Component, @Task, @Service, ...) to a function
instead of a class registered nothing and raised nothing — the bare form silently rebound
the function's name to a namespace instance, and the factory form registered the function
as if it were a class. Both now raise TypeError at decoration time, naming the namespace
and the offending object. @Repository applied directly to a function (entity left out)
raises the same way. See Namespace.
Config sections in the docs and examples now follow a class's key: a @Service class
Postgres reads [service.postgres] as config.service.postgres, instead of a flat
[postgres]. The section, the Context key and the log file now share one name, and
same-named classes in different namespaces no longer share a section. This is a
convention, not a Config change — any structure still works. See
Config.
A Task's execute() takes no arguments, by design, and the docs no longer suggest
giving it an optional parameter to receive the previous step's result, as the 0.2.0
notes did. Work whose input the caller decides is a Function; a value
that differs per deployment is config, and one that follows from earlier runs is state
kept in a repository. A pipeline passes a task's result on to a function or a component
method, not to another task. See
Task, Function or Agent?.
Corrected pages that had fallen behind 0.3.0: Entity no longer says a
duplicate name silently overwrites (it has raised since 0.2.0), Agent and
Component describe how a server such as odf's offers agents and
functions as tools without mcp_tools(), and Service examples read their
own config section.
Project: Project.from_dict() now imports the module named under [project] app
(if present) before constructing the Context — equivalent to writing import app by
hand before calling from_config()/from_dict(). from_config() gets this for free
since it delegates to from_dict(). Fails fast on a missing/broken module (ImportError
propagates uncaught). See Project.
Config: Config.get() and Config[] now run the key through normalize(), same as
__getattr__, so snake_case/kebab-case/PascalCase are interchangeable on every access
path — including looking a section up by its class-derived name (e.g.
config.get("Postgres") for a [postgres] TOML section). See Config.
Component: added McpTool/McpToolsProtocol so a Component can expose its own MCP
tools via an optional mcp_tools() method, detected structurally like
DetailsProtocol/ChartProtocol. Wiring this into an actual MCP server happens on the
odf side. See Component.
Namespace: registering two classes under the same name (via kebab-casing or an
explicit name) used to silently overwrite the earlier registration. Both registration
paths now raise ValueError naming both colliding classes. See Namespace.
Config: Config.__init__ previously stored the given dict by reference, so mutating
the original dict after passing it to Project.from_dict() would silently leak through —
contradicting the documented read-only/immutable contract. Config now owns a deep copy
instead. See Config.
View: renamed ReplayProtocol.replay_field to field, aligning with the generic
field/fields naming convention used by every other View variant. See View.
Clarified that a Task's execute() can already accept an optional parameter to receive
the previous step's return value directly within a Pipeline's own execute() — no
framework change needed, just previously undocumented. The only fixed zero-arg contract
is Context.execute(name), the external CLI/UI/MCP entry point. See Pipeline.
Fixed the Startup/Shutdown Order diagrams in Context, which mislabeled
Config as a Component. Config isn't decorated with @Component and is pre-seeded
before resolution starts, so Lifecycle never iterates over it.