A Methodology for Porting an Agent Harness to Constrained Platforms — Android as a Case Study
Moving an Agent runtime that works well on a desktop or server to mobile is usually hard not because of the model, but because of the total collapse of platform assumptions:
processes no longer live forever, storage is no longer freely writable, the runtime is no longer complete, background execution is no longer free, and the tool surface is no longer provided by the operating system.This article does not drill into any single product's implementation. Instead, it distills a real round of Android porting (extracting the ReAct core of a microkernel Agent framework into a cross-platform Harness) into a reusable methodology: how to inventory the differences, how to slice the abstraction layer, how to design reliability, how to verify it, and which anti-patterns are guaranteed to bite.
1. Methodology at a Glance
Platform porting tends to fail when done "discover-as-you-write": you find only at the very end that some assumption cannot hold on both platforms at once.
We converged on five steps, and the order itself is the method:
| Step | What to do | Deliverable |
|---|---|---|
| ① Difference inventory | Write down the runtime's implicit assumptions one by one, instead of writing code first | Platform capability matrix (which doubles as the abstraction layer's requirements list) |
| ② Abstraction slicing | Slice along the "axis of change": platform capability / domain form | A set of interfaces with zero platform types + host adapters |
| ③ Contractualization | Write "what must always hold" as contracts and invariants | Interface contract document + safe defaults |
| ④ Reliability layering | Design separately around "state / side effects / recovery" instead of vaguely "adding exception handling" | Three-layer mechanisms (snapshots, pre-write records, tiered recovery) |
| ⑤ Verifiability by design | The decision basis comes before the implementation; gates are written together with their boundaries | Pure-function decisions, contract-chain tests, build-time gates |
One criterion runs through all five steps: when you switch hosts, do you change the adapter or the core?
If only the adapter needs changing, the abstraction layer is sliced correctly.
2. Difference Inventory: Turn "Platform Assumptions" into a Table
Don't start from code structure. List the implicit assumptions the runtime depends on and interrogate each one:
Does this assumption still hold? If not, what is the replacement? Which layer should own the replacement?
Taking Android relative to desktop (PC/JVM) as an example, the general dimensions are these nine:
| Assumption dimension | Desktop default | Reality on a constrained platform | Which layer the replacement lands in |
|---|---|---|---|
| Process lifetime | Long-lived; a crash is an anomaly | Being killed is routine (background reclaim, memory pressure, vendor power-saving policies) | Reliability layer: recoverable snapshots and side-effect accounting |
| Storage semantics | Any path is readable/writable and externally visible | Private directories + scoped storage; a directory that is not externally visible equals "not delivered" | Platform layer: logical path → physical path mapping and visibility policy |
| Runtime completeness | Complete standard library | Missing higher-version APIs, and release builds strip classes | Platform layer: capability probing + degradation + truthful reporting |
| Background execution | Essentially unrestricted | Can be suspended, delayed, launch-restricted | Host layer: task-level leases, watchdog, user authorization guidance |
| Capability surface | The OS provides a full toolchain | Capabilities are mostly app-defined (registry implementations or OS subsets) | Domain layer: tool protocol + truthful reporting of capability gaps |
| Human interaction | You can ask a person anytime | Often no one is present (locked screen, background, unattended) | Platform layer: a confirmation gate that must default to deny |
| Resource budget | Relatively generous | Battery, memory, and data are all tight | All layers: tiered budgets and truncation strategy |
| Extension mechanism | Cross-platform | Dynamic loading is mostly platform-proprietary | Domain layer: keep exactly one cross-platform extension point |
| Device consistency | One system, one behavior | The same APK may behave differently on different ROMs | Host layer: real-device inspection and canary delivery |
The point of this table is not to "reference" but to serve as a requirements list: each row's "where the replacement lands" becomes a capability in the abstraction layer.
The earlier the inventory is done, the less rework later — most of the problems we hit were, at bottom, some row of assumptions that was never written down.
3. Abstraction Layer: Slice Along the Axis of Change
3.1 The two axes must be kept separate
The most common mistake in platform porting is mixing two kinds of change into one abstraction:
- Platform capability: what the host can provide (files, paths, time, logging, confirmation, persistence);
- Domain form: what a tool is and how it executes (built-in function table, command line, remote protocol…).
The consequence of mixing axes is very concrete: change one tool form and you must change the platform implementation; change one platform and you must rewrite the tool layer.
The correct slice is inject capabilities as an aggregate value object, isolate form behind an execution protocol, with the core recognizing only interfaces.
3.2 Four design disciplines
- Inject capabilities; do not use global singletons. Global mutable state makes it impossible to run two independent instances safely in the same process
(multi-tenancy and test isolation all break). Every new capability is added as a replaceable field on the aggregate root, with a default value. - The core stays zero-platform-type, enforced by a build-time gate. But a gate always has boundaries: for example, one that only matches import statements
will miss fully-qualified calls and indirect references. The boundary must be written into the documentation — otherwise the next person reads "gate is green"
as "zero platform dependencies", which is exactly the most dangerous misunderstanding. - New capabilities must come with safe defaults: confirmation-type capabilities default to deny, storage-type capabilities default to an in-memory implementation,
dangerous capabilities default to off. The selection criterion for a default is "nothing goes wrong when it's misconfigured", not "convenient for running the demo". - Keep the minimum usable surface as small as possible: push required items to the minimum (for example, requiring only "filesystem + path resolution"),
leaving everything else with a safe default. The more required items, the more error-prone integration becomes.
4. Contractualization: Nail Down the Invariants
The abstraction layer answers "can it be connected"; contracts answer "will it go wrong once connected". Five kinds are worth writing as contracts:
① The directionality of safe defaults. Operations that require a human decision to authorize must be denied when no one is present,
and you must distinguish three outcomes — "the user explicitly denied", "no one to ask", "timed out undecided" — because their audit semantics and their wording to the model all differ.
Turning the confirmation gate into "always allow" makes the tests all green, but that is a security hole, not a shortcut.
② Consistency of the side-effect protocol. A tool executor does not express business failure by throwing (it uses a result object),
because an exception escalates "tool failure" into "loop failure"; but a cancellation exception must propagate upward as-is, otherwise the "stop" semantics break.
Rules like this belong in the interface contract, not left for the implementer to guess.
③ A minimal state machine. For example, "only in-progress tasks can be resumed, terminal states cannot",
and "the terminal state is written exactly once (closed out in finally)". The smaller the state machine, the smaller the surface for error.
④ Idempotency and repair. Duplicate writes under the same identifier must preserve the existing state (both concurrency and replay will trigger this);
before writing, an append-only log must confirm the integrity of the previous record (if it crashed mid-write, the new record sticks to the residual line and both are ruined together).
⑤ Order is semantics. Side-effect accounting must "write before execution, write after execution" — skip a step and it degrades into after-the-fact accounting,
leaving the crash window wide open; if a termination branch in a loop changes from "return" to "break", it will keep executing the wrap-up code after the loop
and silently overwrite a conclusion already reached — order problems like these can only be held by contracts and tests.
There is also a meta-contract: reliability-enhancing components must not become a new point of failure.
A failed snapshot write or a failed log write should not interrupt the main task, but it must leave a visible reporting channel (silence by default is acceptable;
silent and unobservable is not).
5. Reliability Layering: State / Side Effects / Recovery
The single fact that "the process can die at any time" must be handled by three layers separately. Missing any one leaves a real loss:
5.1 State layer: make every step recoverable
- Set the snapshot granularity by "worst-case loss": once per step, not once every N steps — the latter loses an entire stretch of context and tool observations on a long task.
- Snapshots must be tiered and truncated: make "keep the most recent few complete + compress the older ones into a summary" the default strategy,
otherwise a single snapshot grows linearly with task length (our measurement went from 1.6 MB down to about 215 KB, with median disk-write latency from 29 ms → 8 ms,
and reads dropping to a 2–6 ms early exit instead of "full decode"). - Exceptions must be explicitly preserved: some model vendors require the chain of thought to be sent back in subsequent requests;
if compression drops it too, resuming will fail outright. Protocol requirements outrank size optimization. - Atomicity substitutes: if the abstraction layer has no rename capability, "write first, delete after + never delete the one just written + tombstone-style cleanup"
is a workable combination — guaranteeing that at least one complete snapshot is readable at any moment.
5.2 Side-effect layer: define the crash window, then plug it
Tool side effects cannot be rolled back, so "record after execution" is never enough:
- The crash window is defined as "the side effect has happened, but the completion record has not yet landed on disk";
- The countermeasure is to write a pending-confirmation record before execution, and this step's persistence barrier cannot be skipped; the result record after execution can be relaxed
(because "losing the result record" errs in the safe direction); - How to handle it on recovery is the soul of this mechanism: a pending-confirmation record must neither be replayed automatically (it would duplicate file writes, messages, orders)
nor be treated as if it never happened (the task would silently lose a step). The only safe action is to inject it into the context as a fact
and hand the judgment back to the model — "first verify the actual result, then decide whether to retry; do not just execute it again"; - The accounting itself must obey the credentials prohibition: parameters are recorded only as summaries, error text has the raw command line stripped first, and no key ever appears in the logs.
5.3 Recovery layer: decision and mutual exclusion
- Make the recovery decision a pure function: no snapshot / already terminal / auto-resume disabled / previous round had consecutive errors /
lock held by someone else / can auto-resume. Any case that "would require asking the user" is uniformly folded into "run as a new task",
avoiding "the decision needs to ask the user but the task is silently run anyway". - There is often more than one recovery entry point (automatic resume on cold start, manual continue by the user, background keep-alive trigger);
they may land in different processes, which an in-process lock cannot stop; cross-process mutual exclusion needs a combination: atomic file replacement + read-back verification after writing +
an in-process monitor. Atomic replacement alone cannot produce "exactly one winner", and read-back alone lets two executors each read back their own copy. - Lease values must be justified in both directions: too short and a running resume is judged expired and preempted (the most dangerous duplicate execution);
too long and the "ghost lock" left behind after a process is killed shuts the user out. We ultimately chose the magnitude of "the single worst step",
and had each step's disk write renew the lease along the way, decoupling the lock's validity from "the duration of one task" into "the interval between two disk writes". - Cold start has two phases: first do only the decision and accounting, and execute once the dependencies needed to run are ready.
Otherwise the task launches while "dependencies have not yet been injected" and is guaranteed to fail once.
6. Resource Governance: Tiered Budgets and Leases
Mobile resource governance has two general practices:
① Budgets are layered, and each layer's truncation behavior must be specified.
The same "output too large" problem has different caps and different handling at each layer (annotated truncation / silent truncation / spill to disk with a path).
Without that uniformity, the troubleshooter can never say clearly "which layer the output was lost at".
A practical default strategy is: keep recent content complete, compress old content into summaries, and store over-limit content off-screen with a searchable location.
② Keep-alive is not "hold the lock always" but "hold the lock only while active".
Build keep-alive as a task-state-driven lease, where every parameter has a reason rather than a copied rule of thumb:
| Parameter | How to choose the value |
|---|---|
| Idle release | Covers the interval between task wrap-up and a user's follow-up question; a fixed long-held lock can differ in power draw by an order of magnitude |
| Single lease term | Must be re-decided on expiry; never hold the lock indefinitely until the battery dies |
| Renewal threshold | Leaves enough safety margin to guarantee the watchdog period is always smaller than it |
| Watchdog | In step with the system's existing wakeups (adding no extra wakeups), and confirms "there is really work" on every wake |
| Exact timing | Falls back to permission-free inexact timing without exact-alarm permission — the latter also penetrates power-saving mode, just with relaxed precision |
Three more boundaries: no pre-held lock on cold start (zero power draw when there is no task); permission guidance is done once and keeps a manual entry;
all policies are extracted into pure functions with time passed in explicitly, so they can be tested without load.
③ Observability must answer "is it worth it", not "does it look fast".
The metrics must include the historical worst value and the most recent value — averages are diluted by "most steps being fast",
while real-device jank comes precisely from the worst step. Instrumentation itself obeys one discipline: only increment; never change control flow.
7. Verifiability: The Decision Basis Comes Before the Implementation
Platform porting has several verification requirements that differ from ordinary business development:
- Turn decisions into pure functions and make the clock explicit. Logic like tiered decisions, keep-alive policies, and guidance timing is extracted into pure functions with time passed in explicitly,
so boundaries like "timeout" and "lease expiry" can be tested at second granularity and independently of load; otherwise they can only be tested by real waiting, and in the end nobody tests them. - Cross-component contract-chain tests are mandatory. Without going through the model — without even going through the engine — wire the components together in the production call order,
verifying that "keys, reading conventions, and defaults" still agree at the boundaries. All-green unit tests per component never mean the chain is still connected. - Do destructive verification: revert a decision to its old form and the test must go red. Otherwise you cannot distinguish "this decision really guards something"
from "it just happened not to be triggered". Likewise, test fixtures involving a time window must actually cross the window itself, not manufacture signals that would never occur in reality. - Gates must cover all subpackages, and boundaries must be documented. A gap in coverage is more dangerous than no gate at all, because it manufactures a false sense of safety.
- Real-device boundaries must be stated honestly. For example, we do not remote-control real devices for UI testing,
so behaviors that "require long-term real-device observation" (actual battery life under power-saving mode, vendor ROM differences) are only covered at the policy and orchestration layers,
and the real data is collected continuously through canary releases and user feedback. - Consistency must be guarded by machines. Anything "text shown to the model" (system prompts, skill docs, command index) and the implementation
will inevitably drift; the correct approach is not "remember to sync" but to bring them into static scanning,
and to register retired capabilities on a guardian test allowlist when they are removed.
8. Anti-Pattern List
The most practical output of a methodology is a "don't do this" list. These are the ones actually encountered in this round of porting:
| Anti-pattern | Consequence | Correct approach |
|---|---|---|
| Two copies of the same abstraction layer, relying on "remember to sync" | Silent semantic divergence; tests don't go red | Single source of truth; moving is moving, not copying |
| Dangerous capabilities on by default, or a "self-service enable" meta-tool | The model can escalate its own privileges; the switch is meaningless | Off by default + structured enablement guidance + explicit user enabling |
| Writing the confirmation gate as "always allow" or default-allow | High-risk operations execute silently in unattended scenarios | fail-closed, and distinguish denied / no one to ask / timed out |
| Reliability components themselves throwing | A full disk or insufficient permission becomes a new crash source | Snapshot/log failure does not interrupt the main task, but is observable |
| Recording side effects only "after execution" | Duplicate execution inside the crash window (double writes, double sends) | Account before execution + on recovery only prompt for verification, forbid automatic replay |
| Inferring identity from filesystem timestamps | Lossy mapping causes crossed records / crossed sessions | Content is authoritative; filenames are only for locating |
| Path safety done only with string prefix matching | .. and relative paths can bypass it |
Real-path normalization + resolve against the working directory + no decision without a baseline |
| The same decision wired to only one path | Another path bypasses the protection entirely | A single decision entry point shared by all paths |
| Directly, statically referencing higher-version APIs | Build-time stripping errors, missing classes at runtime | Capability probing + degradation + truthful reporting |
| Evaluating performance with averages | The worst case is concealed | Record the historical worst and the most recent value |
| Claiming the chain works after unit tests alone | Cross-component keys/reading conventions/defaults disagree | Contract-chain integration tests |
| Incomplete gate coverage with no documented boundary | A green gate ≠ no platform dependencies | Cover all subpackages + document the blind spots |
| Global singletons carrying mutable state | Multi-instance / test isolation breaks | Capability aggregate-root injection |
| Treating "the process lives long-term" as a default premise | A long task restarting from scratch the moment it breaks | Every step recoverable + tiered resume |
9. Checklist for Migrating to the Next Platform
Distilling the sections above into a checklist you can follow directly:
- List the runtime's implicit assumptions as a table, each row stating clearly "which layer the replacement lands in when it doesn't hold";
- Slice the abstraction layer along the two axes of "platform capability / domain form", keeping the core free of platform types;
- Give every platform capability a safe default; push required items to the minimum;
- Write the five kinds of invariants as contracts: confirmation, side effects, state machine, idempotency, order;
- Implement reliability in the three layers of state / side effects / recovery; missing one does not count as done;
- Set snapshot granularity by "worst-case loss" and do tiered truncation (explicitly exempt protocol-required fields);
- Make the recovery decision a pure function; do cross-process mutual exclusion with a "file + read-back + in-process monitor" combination;
- Justify every keep-alive parameter, and prepare a degradation path for exact timing;
- Set budgets per layer for output and context, and specify the truncation behavior (annotated / silent / off-screen);
- Observability must include the worst value; instrumentation must not change control flow;
- Make decision logic pure-functional and the clock explicit; test boundaries with a fake clock rather than real waiting;
- Add contract-chain tests across components, and do destructive verification of critical decisions;
- Have build-time gates cover all subpackages, and write the blind spots into the documentation;
- Bring text shown to the model (prompts / docs / index) and the implementation into static consistency scanning;
- Explicitly declare the boundaries of real-device verification and the substitutes (inspection + canary + user feedback).
Closing
Moving an Agent onto a new platform tests not the model's capability, but how much engineering respect you have for platform reality:
processes die, so every step must be recoverable; background execution is limited, so use leases instead of long-held locks;
permissions are requested at runtime, so "declared" does not equal "available"; the capability surface is app-defined, so the text shown to the model must be in sync with the implementation;
paths, libraries, and inter-process communication all differ from the desktop, so platform capabilities should go through injection from day one, rather than being hard-coded.
In the end there is only one criterion: when you switch hosts, are you changing the adapter, or the core?