Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
113 commits
Select commit Hold shift + click to select a range
356dfb7
feat: add wip hacked together start to an agents sdk
1egoman Aug 6, 2025
effb228
fix: address imports
1egoman Aug 7, 2025
ec87981
feat: get agent sdk to compile
1egoman Aug 7, 2025
ed4a803
feat: mostly get AgentParticipant working
1egoman Aug 7, 2025
a926e16
feat: comment out old transcription stuff
1egoman Aug 7, 2025
809e322
feat: mostly get the transcriptions incoming pipeline working
1egoman Aug 7, 2025
7760aec
feat: get CombinedMessageReceiver working
1egoman Aug 7, 2025
5e35853
feat: got transcriptions aggregating properly, it's way way more nuan…
1egoman Aug 7, 2025
3978348
feat: add MessageSender and ChatMessageSender / CombinedMessageSender…
1egoman Aug 7, 2025
b2355fc
feat: add loopback message receiver to ChatMessageSender
1egoman Aug 7, 2025
2bf6419
docs: add more comments
1egoman Aug 7, 2025
abe2026
feat: start migrating pre-existing hooks to agent alternatives
1egoman Aug 8, 2025
4c1c024
refactor: break up existing code into many files
1egoman Aug 8, 2025
8fcd2ed
docs: add initial AgentSession docs
1egoman Aug 8, 2025
9425833
feat: add OrderedMessageList
1egoman Aug 8, 2025
4443c04
feat: add more agent specific react hook coverage
1egoman Aug 8, 2025
994b209
feat; add stubs for a log of additional functionality lukas proposed …
1egoman Aug 8, 2025
1658dc9
feat: add waitUntilAgentIsAvailable
1egoman Aug 8, 2025
76a3962
feat: implement message aggregator idea
1egoman Aug 8, 2025
fb01009
docs: add docs for message aggregator idea
1egoman Aug 11, 2025
b4ba41f
feat: move agent state into AgentParticipant
1egoman Aug 11, 2025
b192a46
feat: fix useAgentLocalParticipant local participant mic track not sh…
1egoman Aug 11, 2025
6b9dfc5
feat: remove dead code from AgentParticipant
1egoman Aug 11, 2025
58a5ad3
feat: rename AgentParticipant to Agent
1egoman Aug 11, 2025
d38ddf6
docs: remove comments
1egoman Aug 11, 2025
91f4a08
feat: add "ready" state to useAgentMessages
1egoman Aug 11, 2025
728258b
feat: add most of agent control bar behind the scenes logic into useA…
1egoman Aug 11, 2025
0d312e3
feat: port useDebug to use AgentSession
1egoman Aug 11, 2025
22282c2
fix: agent timeout should disconnect whole AgentSession
1egoman Aug 11, 2025
4d22785
refactor: update docs
1egoman Aug 11, 2025
b2602b8
feat: add MessageReceived event, aggregation should be the responsibi…
1egoman Aug 11, 2025
6f0eeb5
refactor: comment out dead code
1egoman Aug 11, 2025
d24be87
fix: remove SentMessage from ChatEntryProps
1egoman Aug 14, 2025
8f6301b
feat: add special case for sendMessage string -> SentChatMessage
1egoman Aug 14, 2025
00674f4
feat: add ConnectionDetailsProvider to further abstract generating do…
1egoman Aug 14, 2025
34c3645
fix: add missing package
1egoman Aug 15, 2025
0d6288e
feat: remove defaultAggregator and startsAt param to createMessageAgg…
1egoman Aug 18, 2025
6fae521
feat: add explicit AgentSessionEvent.Disconnected event
1egoman Aug 18, 2025
82a5fe2
feat: use AgentSessionEvent.Disconnected to close any open `ReceivedM…
1egoman Aug 18, 2025
e5b1fc0
feat: make ReceivedMessageAggregator methods arrow functions so they …
1egoman Aug 18, 2025
f4eeace
feat: remove startsAt from TBD react layer
1egoman Aug 18, 2025
ad4c236
feat: add types-emitter package (this wasn't installed for some reason?)
1egoman Aug 18, 2025
730dbed
feat: add logic to ensure that `connect` can't be run until underlyin…
1egoman Aug 18, 2025
80038a0
feat: add better mechanism to control whether microphone is enabled o…
1egoman Aug 18, 2025
c3993be
feat: parameterize agentConnectTimeoutMilliseconds with default value
1egoman Aug 18, 2025
fa6cac4
feat: add dependency to temp react layer so that useAgentMessages rea…
1egoman Aug 18, 2025
e47c9af
feat: replace AgentState with AgentConnectionState / AgentConversatio…
1egoman Aug 18, 2025
6f12617
fix: add await as part of refresh so return is blocked until after re…
1egoman Aug 18, 2025
1d716d7
feat: add centralized participant attributes enum
1egoman Aug 18, 2025
a7ff04f
feat: remove canSend, proxy all messages to all `MessageSender`s
1egoman Aug 19, 2025
9d7c5b3
feat: add chat message options to SentChatMessage
1egoman Aug 19, 2025
4f34a69
feat: add MediaDevicesError agent session event
1egoman Aug 22, 2025
76c4887
feat: add useAgentMediaDeviceSelect react hook
1egoman Aug 22, 2025
eeab341
feat: add useAgentLocalParticipantPermissions
1egoman Aug 25, 2025
cdecd71
feat: try to see what it would take to integrate zustand
1egoman Aug 25, 2025
b9b0050
feat: add createAgent / AgentInstance
1egoman Aug 25, 2025
e53d4cb
feat: move connectionState onto top level room object
1egoman Aug 25, 2025
9d2b72d
feat: add derived conenction state / matching wait functions
1egoman Aug 25, 2025
a062da9
fix: get agent connection timeout logic to work
1egoman Aug 25, 2025
df581f2
feat: make waitUntilRoomConnected / waitUntilRoomDisconnected instead…
1egoman Aug 25, 2025
4a56259
fix: regrettable spelling error :grimace:
1egoman Aug 25, 2025
33fa2bb
feat: move messages related logic out into separate messages key
1egoman Aug 25, 2025
32532e5
feat: add matching "local" to go with "agent" with "local tracks" inside
1egoman Aug 25, 2025
c84aedc
fear: add RemoteTrack to Agent
1egoman Aug 26, 2025
5216add
feat: get rid of unsupported devices properties for non camera / micr…
1egoman Aug 26, 2025
69d854e
feat: add AgentStartAudio component
1egoman Aug 26, 2025
3982799
feat: add AgentVideoTrack / AgentAudioTrack / AgentRoomAudioRenderer …
1egoman Aug 26, 2025
d6094c4
feat: get working useAgentEvents with all the fancy type inference st…
1egoman Aug 26, 2025
7ee05e4
feat: fully cut over to new agentsession abstraction
1egoman Aug 26, 2025
c676977
feat: remove motion wrapping of AgentVideoTrack to get rendering to w…
1egoman Aug 27, 2025
b36eb97
refactor: remove dead code and fix typescript type errors
1egoman Aug 27, 2025
57cf407
fix: migrate AgentConnectionState -> AgentSessionConnectionState
1egoman Aug 27, 2025
39500d8
refactor: remove dead code
1egoman Aug 27, 2025
d600c4b
feat: port BarVisualizer -> AgentBarVisualizer
1egoman Aug 27, 2025
a2c02a7
fix: rename options types
1egoman Aug 29, 2025
c1cc2cd
feat: swap out useAgentSession for createUseAgentSession
1egoman Aug 29, 2025
615d692
fix: make track publication checking logic more robust
1egoman Aug 29, 2025
3a3609c
fix: make AgentStartAudio label optional
1egoman Aug 29, 2025
601fa29
feat: add single page demo
1egoman Aug 29, 2025
aae69ac
feat: add preconfigured useAgentSession
1egoman Aug 29, 2025
c570ad7
fix: address some low handing fruit type errors
1egoman Aug 29, 2025
b8aa239
fix: get rid of AgentSessionEvent.MessageReceived, opt for using the …
1egoman Aug 29, 2025
d6dbe73
feat: move emitters inside agent sdk abstraction layers
1egoman Aug 29, 2025
7bd7c36
fix: clean up imports / dead code
1egoman Aug 29, 2025
46fd6d5
feat: add sendPending state for tracking when message send is in prog…
1egoman Aug 29, 2025
5c6ad68
feat: get rid of need for second undefined options param to send()
1egoman Aug 29, 2025
d7b22cb
feat: move initialize / teardown into subtle layer
1egoman Aug 29, 2025
893441e
fix: stream -> events
1egoman Aug 29, 2025
d535d08
fix: ensure that agent nested participant event handlers are properly…
1egoman Aug 29, 2025
d54bbed
feat: add discriminated unions to all but LocalTrack
1egoman Aug 29, 2025
395c83b
feat: fix subtle logic bugs related to descriminated union work
1egoman Aug 29, 2025
9aafd93
feat: add waitUntilCamera / waitUntilMicrophone
1egoman Sep 2, 2025
923afb3
feat: get rid of conditional property accesses, for the most part
1egoman Sep 2, 2025
b83b025
feat: move single page demo to its own route
1egoman Sep 2, 2025
1b52a7d
fix: ensure canPlayAudio is initialized to the proper value
1egoman Sep 2, 2025
6d634c8
feat: make write to console toggleable
1egoman Sep 2, 2025
3ddb872
fix: ensure that chat messages are sent on the right topic
1egoman Sep 2, 2025
1b352d8
feat: add proper message id generation
1egoman Sep 2, 2025
5f0baed
fix: switch order of visibleControls checks
1egoman Sep 2, 2025
790386b
fix: make emitter internally defined
1egoman Sep 2, 2025
d6d9516
fix: remove extra layer of indirection
1egoman Sep 2, 2025
cc2b17a
feat: add PermissionsChanged event
1egoman Sep 2, 2025
e67c286
fix: get rid of debug window assignment
1egoman Sep 2, 2025
9410259
fix: remove dead code
1egoman Sep 2, 2025
2bcc1e0
feat: refresh device list when a new device is selected
1egoman Sep 2, 2025
44e879f
feat: allow useAgentEvents first parameter to be nullish
1egoman Sep 3, 2025
f1f70ee
feat: rename ConnectionCredentialsProvider -> ConnectionCredentials a…
1egoman Sep 4, 2025
0ba19ab
refactor: get rid of AgentSessionEvent.Connected / AgentSessionEvent.…
1egoman Sep 4, 2025
c51f21d
refactor: rename AgentSessionEvent.AgentConnectionStateChanged -> Age…
1egoman Sep 4, 2025
a1f408a
fix: address logic bug causing handleDeviceChange to get called for n…
1egoman Sep 4, 2025
7bfa2ff
fix: swap NodeJS.Timeout -> ReturnType<typeof setTimeout> at the requ…
1egoman Sep 4, 2025
9a7b940
refactor: get rid of Agent prefix on some agent events
1egoman Sep 4, 2025
3b16d1d
refactor: get rid of AgentAttributes type alias
1egoman Sep 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
440 changes: 440 additions & 0 deletions agent-sdk/agent-session/Agent.ts

Large diffs are not rendered by default.

466 changes: 466 additions & 0 deletions agent-sdk/agent-session/AgentSession.ts

Large diffs are not rendered by default.

118 changes: 118 additions & 0 deletions agent-sdk/agent-session/ConnectionCredentialsProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { decodeJwt } from 'jose';

import { ConnectionDetails } from "@/app/api/connection-details/route";

const ONE_MINUTE_IN_MILLISECONDS = 60 * 1000;

/**
* ConnectionDetails handles getting credentials for connecting to a new Room, caching
* the last result and using it until it expires. */
export abstract class ConnectionCredentials {
private cachedConnectionDetails: ConnectionDetails | null = null;

protected isCachedConnectionDetailsExpired() {
const token = this.cachedConnectionDetails?.participantToken;
if (!token) {
return true;
}

const jwtPayload = decodeJwt(token);
if (!jwtPayload.exp) {
return true;
}
const expiresAt = new Date(jwtPayload.exp - ONE_MINUTE_IN_MILLISECONDS);

const now = new Date();
return expiresAt >= now;
}

async generate() {
if (this.isCachedConnectionDetailsExpired()) {
await this.refresh();
}

return this.cachedConnectionDetails!;
}

async refresh() {
this.cachedConnectionDetails = await this.fetch();
}

protected abstract fetch(): Promise<ConnectionDetails>;
};

export class ManualConnectionCredentials extends ConnectionCredentials {
protected fetch: () => Promise<ConnectionDetails>;

constructor(handler: () => Promise<ConnectionDetails>) {
super();
this.fetch = handler;
}
}

export class LiteralConnectionCredentials extends ConnectionCredentials {
payload: ConnectionDetails;

constructor(payload: ConnectionDetails) {
super();
this.payload = payload;
}

async fetch() {
if (this.isCachedConnectionDetailsExpired()) {
// FIXME: figure out a better logging solution?
console.warn('WARNING: The credentials within LiteralConnectionCredentials have expired, so any upcoming room connections will fail.');
}
return this.payload;
}

async refresh() { /* cannot refresh a literal set of credentials! */ }
}


type SandboxConnectionCredentialsOptions = {
sandboxId: string;
baseUrl?: string;

/** The name of the room to join. If omitted, a random new room name will be generated instead. */
roomName?: string;

/** The identity of the participant the token should connect as connect as. If omitted, a random
* identity will be used instead. */
participantName?: string;
};

export class SandboxConnectionCredentials extends ConnectionCredentials {
protected options: SandboxConnectionCredentialsOptions;

constructor(options: SandboxConnectionCredentialsOptions) {
super();
this.options = options;

if (process.env.NODE_ENV === 'production') {
// FIXME: figure out a better logging solution?
console.warn('WARNING: SandboxConnectionCredentials is meant for development, and is not security hardened. In production, implement your own token generation solution.');
}
}

async fetch() {
const baseUrl = this.options.baseUrl ?? "https://cloud-api.livekit.io";
const response = await fetch(`${baseUrl}/api/sandbox/connection-details`, {
method: "POST",
headers: {
"X-Sandbox-ID": this.options.sandboxId,
"Content-Type": "application/json",
},
body: JSON.stringify({
roomName: this.options.roomName,
participantName: this.options.participantName,
}),
});

if (!response.ok) {
throw new Error(`Error generting token from sandbox token server: ${response.status} ${await response.text()}`);
}

return response.json();
}
}
134 changes: 134 additions & 0 deletions agent-sdk/agent-session/Local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { ParticipantEvent, Room, RoomEvent } from 'livekit-client';
import type TypedEventEmitter from 'typed-emitter';
import { EventEmitter } from "events";
import { LocalParticipant, Track } from 'livekit-client';
import { createLocalTrack, LocalTrackInstance } from './LocalTrack';
import { ParticipantPermission } from 'livekit-server-sdk';
import { trackSourceToProtocol } from '../external-deps/components-js';
import { createScopedGetSet } from '../lib/scoped-get-set';

export enum LocalEvent {
PermissionsChanged = 'permissionsChanged',
};

export type LocalCallbacks = {
[LocalEvent.PermissionsChanged]: (permissions: ParticipantPermission | null) => void;
};

export type LocalInstance = {
[Symbol.toStringTag]: "LocalInstance";

permissions: ParticipantPermission | null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if local is only available in a connected state, then I believe permissions can be set always

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least in the underlying participant implementation, the type for permissions is ParticipantPermission | undefined - see here, so I just proxied that through.

Are you saying that in this particular case, it will always be set for a localParticipant?

If so, I can get rid of the null but it seems like that might also be a change worth upstreaming to the client sdk as well at the same time?

If not, what would the "fallback" value be here if permissions wasn't set?

publishPermissions: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exposing both this and permissions above seems a bit confusing, why not consolidate them?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somewhere I had left a comment (I'm not sure where, I thought it was here 🤔 ) that maybe it makes sense to break this value up into individual "published" booleans on each underlying track type?

Alternatively, maybe permissions could just be moved inside of the subtle key or gotten rid of completely, it doesn't look like it's being explicitly used currently by the starter app and I see needing to access it as probably more of an advanced use case.

camera: boolean | null;
microphone: boolean | null;
screenShare: boolean | null;
data: boolean;
};

camera: LocalTrackInstance<Track.Source.Camera>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

naming nit, LocalTrackInstance suggests the local track exists already when interacting with it

microphone: LocalTrackInstance<Track.Source.Microphone>;
screenShare: LocalTrackInstance<Track.Source.ScreenShare>;

subtle: {
emitter: TypedEventEmitter<LocalCallbacks>;
initialize: () => void;
teardown: () => void;

localParticipant: LocalParticipant;
};
};

export function createLocal(
room: Room,
get: () => LocalInstance,
set: (fn: (old: LocalInstance) => LocalInstance) => void,
): LocalInstance {
const emitter = new EventEmitter() as TypedEventEmitter<LocalCallbacks>;

const handleParticipantPermissionsChanged = () => {
const permissions = room.localParticipant.permissions ?? null;

const canPublishSource = (source: Track.Source) => {
return (
permissions?.canPublish &&
(permissions.canPublishSources.length === 0 ||
permissions.canPublishSources.includes(trackSourceToProtocol(source)))
);
};

set((old) => ({
...old,
permissions,
publishPermissions: { // FIXME: figure out a better place to put this? Maybe in with tracks?
camera: canPublishSource(Track.Source.Camera) ?? null,
microphone: canPublishSource(Track.Source.Microphone) ?? null,
screenShare: canPublishSource(Track.Source.ScreenShare) ?? null,
data: permissions?.canPublishData ?? false,
},
}));

emitter.emit(LocalEvent.PermissionsChanged, permissions);
};

const initialize = () => {
get().camera.subtle.initialize();
get().microphone.subtle.initialize();
get().screenShare.subtle.initialize();

room.on(RoomEvent.ParticipantPermissionsChanged, handleParticipantPermissionsChanged);
};

const teardown = () => {
room.localParticipant.off(ParticipantEvent.ParticipantPermissionsChanged, handleParticipantPermissionsChanged);

get().camera.subtle.teardown();
get().microphone.subtle.teardown();
get().screenShare.subtle.teardown();
};

const { get: trackGet, set: trackSet } = createScopedGetSet(get, set, 'camera', 'LocalTrack');
const camera = createLocalTrack({
room,
trackSource: Track.Source.Camera,
preventUserChoicesSave: false,
}, trackGet, trackSet);

const { get: microphoneTrackGet, set: microphoneTrackSet } = createScopedGetSet(get, set, 'microphone', 'LocalTrack');
const microphone = createLocalTrack({
room,
trackSource: Track.Source.Microphone,
preventUserChoicesSave: false,
}, microphoneTrackGet, microphoneTrackSet);

const { get: screenShareTrackGet, set: screenShareTrackSet } = createScopedGetSet(get, set, 'screenShare', 'LocalTrack');
const screenShare = createLocalTrack({
room,
trackSource: Track.Source.ScreenShare,
preventUserChoicesSave: false,
}, screenShareTrackGet, screenShareTrackSet);

return {
[Symbol.toStringTag]: "LocalInstance",

permissions: room.localParticipant.permissions ?? null,
publishPermissions: {
camera: null,
microphone: null,
screenShare: null,
data: false,
},

camera,
microphone,
screenShare,

subtle: {
emitter,
initialize,
teardown,

localParticipant: room.localParticipant,
},
};
}
Loading