Skip to content

Release Notes

0.6.0

Added

  • 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.

Changed (breaking)

  • 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.

Fixed

  • 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).

0.5.0

Added

  • 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.

Changed

  • 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.

0.4.0

Added

  • 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.

0.3.0

Added

  • 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.

Changed

  • 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 def execute(), 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 ValueError call() 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.

Fixed

  • 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.

Docs

  • 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.

0.2.0

Added

  • 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.

Fixed

  • 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.

Changed

  • View: renamed ReplayProtocol.replay_field to field, aligning with the generic field/fields naming convention used by every other View variant. See View.

Docs

  • 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.